feat: categories have settings for filtering and titles

This commit is contained in:
2026-02-20 21:10:15 +01:00
parent eeffa247bb
commit 63c4b148e1
15 changed files with 661 additions and 53 deletions

View File

@@ -11,8 +11,10 @@ import {
PREVIEW_ASSETS, PREVIEW_ASSETS,
PREVIEW_IMAGE_ASSETS, PREVIEW_IMAGE_ASSETS,
buildCanonicalPostPath, buildCanonicalPostPath,
type CategoryRenderSettings,
type HtmlRewriteContext, type HtmlRewriteContext,
} from './PageRenderer'; } from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
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;
@@ -27,6 +29,8 @@ export interface BlogGenerationOptions {
maxPostsPerPage?: number; maxPostsPerPage?: number;
language?: string; language?: string;
pageTitle?: string; pageTitle?: string;
picoTheme?: PicoThemeName;
categorySettings?: Record<string, CategoryRenderSettings>;
sections?: BlogGenerationSection[]; sections?: BlogGenerationSection[];
} }
@@ -81,6 +85,30 @@ function clampMaxPostsPerPage(value: unknown): number {
return normalized; return normalized;
} }
function resolveCategorySettings(
value: Record<string, CategoryRenderSettings> | undefined,
): 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 },
};
if (!value) {
return defaults;
}
const merged = { ...defaults };
for (const [category, settings] of Object.entries(value)) {
merged[category] = {
renderInLists: settings?.renderInLists !== false,
showTitle: settings?.showTitle !== false,
};
}
return merged;
}
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');
@@ -201,9 +229,22 @@ 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 listExcludedCategories = Object.entries(categorySettings)
.filter(([, settings]) => settings.renderInLists === false)
.map(([category]) => category);
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage); const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' }); const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' }); const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
const publishedListCandidates = await this.postEngine.getPostsFiltered({
status: 'published',
excludeCategories: listExcludedCategories,
});
const draftListCandidates = await this.postEngine.getPostsFiltered({
status: 'draft',
excludeCategories: listExcludedCategories,
});
const publishedSnapshots = await Promise.all( const publishedSnapshots = await Promise.all(
publishedCandidates.map(async (post) => { publishedCandidates.map(async (post) => {
@@ -214,6 +255,15 @@ export class BlogGenerationEngine {
const draftPublishedSnapshots = await Promise.all( const draftPublishedSnapshots = await Promise.all(
draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)), draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)),
); );
const publishedListSnapshots = await Promise.all(
publishedListCandidates.map(async (post) => {
const snapshot = await this.postEngine.getPublishedVersion(post.id);
return snapshot || post;
}),
);
const draftListPublishedSnapshots = await Promise.all(
draftListCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)),
);
const publishedPostById = new Map<string, PostData>(); const publishedPostById = new Map<string, PostData>();
for (const post of publishedSnapshots) { for (const post of publishedSnapshots) {
@@ -227,6 +277,17 @@ export class BlogGenerationEngine {
const publishedPosts = Array.from(publishedPostById.values()) const publishedPosts = Array.from(publishedPostById.values())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const publishedListPostById = new Map<string, PostData>();
for (const post of publishedListSnapshots) {
publishedListPostById.set(post.id, post);
}
for (const snapshot of draftListPublishedSnapshots) {
if (snapshot) {
publishedListPostById.set(snapshot.id, snapshot);
}
}
const publishedListPosts = Array.from(publishedListPostById.values())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const feedPosts = publishedPosts.slice(0, maxPostsPerPage); const feedPosts = publishedPosts.slice(0, maxPostsPerPage);
onProgress(3, `Found ${publishedPosts.length} published posts`); onProgress(3, `Found ${publishedPosts.length} published posts`);
@@ -240,14 +301,19 @@ export class BlogGenerationEngine {
const postUrls: Array<{ loc: string; lastmod: string }> = []; const postUrls: Array<{ loc: string; lastmod: string }> = [];
for (const post of publishedPosts) { for (const post of publishedPosts) {
for (const tag of post.tags || []) allTags.add(tag);
for (const category of post.categories || []) allCategories.add(category);
const createdAt = resolvePostCreatedAt(post); const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const postUrl = `${options.baseUrl}${canonicalPath}`; const postUrl = `${options.baseUrl}${canonicalPath}`;
const updatedAt = post.updatedAt; const updatedAt = post.updatedAt;
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() }); postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
}
for (const post of publishedListPosts) {
for (const tag of post.tags || []) allTags.add(tag);
for (const category of post.categories || []) allCategories.add(category);
const createdAt = resolvePostCreatedAt(post);
const updatedAt = post.updatedAt;
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');
@@ -266,7 +332,7 @@ export class BlogGenerationEngine {
} }
} }
const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now; const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
onProgress(5, 'Building sitemap XML...'); onProgress(5, 'Building sitemap XML...');
@@ -396,7 +462,7 @@ export class BlogGenerationEngine {
const atomPath = path.join(htmlDir, 'atom.xml'); const atomPath = path.join(htmlDir, 'atom.xml');
const estimatedUnitsBySection = this.estimateGenerationUnitsBySection( const estimatedUnitsBySection = this.estimateGenerationUnitsBySection(
publishedPosts, publishedListPosts,
allCategories, allCategories,
allTags, allTags,
years, years,
@@ -442,7 +508,11 @@ export class BlogGenerationEngine {
const pageTitle = options.pageTitle || options.projectName; const pageTitle = options.pageTitle || options.projectName;
const language = options.language || 'en'; const language = options.language || 'en';
const pageContext = { page_title: pageTitle, language }; const pageContext = {
page_title: pageTitle,
language,
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
};
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine); const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine);
const rewriteContext = this.buildHtmlRewriteContext(publishedPosts); const rewriteContext = this.buildHtmlRewriteContext(publishedPosts);
@@ -451,7 +521,7 @@ export class BlogGenerationEngine {
if (includeCore) { if (includeCore) {
onProgress(20, 'Generating root pages...'); onProgress(20, 'Generating root pages...');
pagesGenerated += await this.generateRootPages(options.projectId, publishedPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress); pagesGenerated += await this.generateRootPages(options.projectId, publishedListPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress);
pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress); pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress);
} }
@@ -462,17 +532,17 @@ export class BlogGenerationEngine {
if (includeCategory) { if (includeCategory) {
onProgress(50, 'Generating category pages...'); onProgress(50, 'Generating category pages...');
pagesGenerated += await this.generateCategoryPages(options.projectId, publishedPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress); pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress);
} }
if (includeTag) { if (includeTag) {
onProgress(65, 'Generating tag pages...'); onProgress(65, 'Generating tag pages...');
pagesGenerated += await this.generateTagPages(options.projectId, publishedPosts, allTags, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress); pagesGenerated += await this.generateTagPages(options.projectId, publishedListPosts, allTags, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress);
} }
if (includeDate) { if (includeDate) {
onProgress(80, 'Generating date archive pages...'); onProgress(80, 'Generating date archive pages...');
pagesGenerated += await this.generateDateArchivePages(options.projectId, publishedPosts, years, yearMonths, yearMonthDays, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress); pagesGenerated += await this.generateDateArchivePages(options.projectId, publishedListPosts, years, yearMonths, yearMonthDays, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress);
} }
onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`); onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`);
@@ -561,8 +631,9 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string }, pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageRenderer: PageRenderer, pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage)); const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage));
@@ -579,6 +650,7 @@ export class BlogGenerationEngine {
archiveContext: { kind: 'root' }, archiveContext: { kind: 'root' },
basePathname: '/', basePathname: '/',
pagination: { page, maxPostsPerPage, totalPosts: posts.length }, pagination: { page, maxPostsPerPage, totalPosts: posts.length },
categorySettings,
...pageContext, ...pageContext,
}); });
@@ -598,7 +670,7 @@ export class BlogGenerationEngine {
posts: PostData[], posts: PostData[],
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string }, pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageRenderer: PageRenderer, pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
@@ -627,8 +699,9 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string }, pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageRenderer: PageRenderer, pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; let count = 0;
@@ -652,6 +725,7 @@ export class BlogGenerationEngine {
archiveContext: { kind: 'category', name: category }, archiveContext: { kind: 'category', name: category },
basePathname, basePathname,
pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length }, pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length },
categorySettings,
...pageContext, ...pageContext,
}); });
@@ -676,8 +750,9 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string }, pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageRenderer: PageRenderer, pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; let count = 0;
@@ -701,6 +776,7 @@ export class BlogGenerationEngine {
archiveContext: { kind: 'tag', name: tag }, archiveContext: { kind: 'tag', name: tag },
basePathname, basePathname,
pagination: { page, maxPostsPerPage, totalPosts: tagPosts.length }, pagination: { page, maxPostsPerPage, totalPosts: tagPosts.length },
categorySettings,
...pageContext, ...pageContext,
}); });
@@ -727,8 +803,9 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string }, pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageRenderer: PageRenderer, pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
): Promise<number> { ): Promise<number> {
let count = 0; let count = 0;
@@ -736,7 +813,7 @@ export class BlogGenerationEngine {
for (const [year] of Array.from(yearsMap.entries()).sort((a, b) => b[0] - a[0])) { for (const [year] of Array.from(yearsMap.entries()).sort((a, b) => b[0] - a[0])) {
const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year); const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year);
count += await this.generatePaginatedListPages( count += await this.generatePaginatedListPages(
projectId, yearPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated, projectId, yearPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated,
`${year}`, `/${year}`, { kind: 'year', year }, 'date', `${year}`, `/${year}`, { kind: 'year', year }, 'date',
); );
} }
@@ -750,7 +827,7 @@ export class BlogGenerationEngine {
return d.getFullYear() === year && (d.getMonth() + 1) === month; return d.getFullYear() === year && (d.getMonth() + 1) === month;
}); });
count += await this.generatePaginatedListPages( count += await this.generatePaginatedListPages(
projectId, monthPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated, projectId, monthPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated,
ym, `/${ym}`, { kind: 'month', year, month }, 'date', ym, `/${ym}`, { kind: 'month', year, month }, 'date',
); );
} }
@@ -765,7 +842,7 @@ export class BlogGenerationEngine {
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
}); });
count += await this.generatePaginatedListPages( count += await this.generatePaginatedListPages(
projectId, dayPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated, projectId, dayPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, onPageGenerated,
ymd, `/${ymd}`, { kind: 'day', year, month, day }, 'date', ymd, `/${ymd}`, { kind: 'day', year, month, day }, 'date',
); );
} }
@@ -779,8 +856,9 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number, maxPostsPerPage: number,
htmlDir: string, htmlDir: string,
pageContext: { page_title: string; language: string }, pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageRenderer: PageRenderer, pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void, onPageGenerated: (message: string) => void,
urlPrefix: string, urlPrefix: string,
basePathname: string, basePathname: string,
@@ -803,6 +881,7 @@ export class BlogGenerationEngine {
archiveContext, archiveContext,
basePathname, basePathname,
pagination: { page, maxPostsPerPage, totalPosts: posts.length }, pagination: { page, maxPostsPerPage, totalPosts: posts.length },
categorySettings,
...pageContext, ...pageContext,
}); });

View File

@@ -24,6 +24,12 @@ 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
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
}
export interface CategoryRenderSettings {
renderInLists: boolean;
showTitle: boolean;
} }
const DEFAULT_MAX_POSTS_PER_PAGE = 50; const DEFAULT_MAX_POSTS_PER_PAGE = 50;
@@ -64,11 +70,13 @@ 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);
return { return {
...metadata, ...metadata,
publicUrl, publicUrl,
maxPostsPerPage, maxPostsPerPage,
picoTheme, picoTheme,
categorySettings,
}; };
} }
@@ -77,6 +85,38 @@ 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> {
return {
article: { renderInLists: true, showTitle: true },
picture: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
};
}
function normalizeCategorySettings(value: unknown): Record<string, CategoryRenderSettings> {
const defaults = getDefaultCategorySettings();
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 category = normalizeTaxonomyTerm(rawCategory);
if (!category || !rawSettings || typeof rawSettings !== 'object') {
continue;
}
const settings = rawSettings as Record<string, unknown>;
normalized[category] = {
renderInLists: settings.renderInLists !== false,
showTitle: settings.showTitle !== false,
};
}
return normalized;
}
/** /**
* MetaEngine manages project metadata like available tags and categories. * MetaEngine manages project metadata like available tags and categories.
* *
@@ -238,6 +278,21 @@ export class MetaEngine extends EventEmitter {
const normalizedCategory = normalizeTaxonomyTerm(category); const normalizedCategory = normalizeTaxonomyTerm(category);
if (normalizedCategory && !this.categories.has(normalizedCategory)) { if (normalizedCategory && !this.categories.has(normalizedCategory)) {
this.categories.add(normalizedCategory); this.categories.add(normalizedCategory);
const currentMetadata = this.projectMetadata;
if (currentMetadata) {
const currentSettings = normalizeCategorySettings(currentMetadata.categorySettings);
if (!currentSettings[normalizedCategory]) {
currentSettings[normalizedCategory] = {
renderInLists: true,
showTitle: true,
};
this.projectMetadata = normalizeProjectMetadata({
...currentMetadata,
categorySettings: currentSettings,
});
await this.saveProjectMetadata();
}
}
this.emit('categoriesChanged', await this.getCategories()); this.emit('categoriesChanged', await this.getCategories());
await this.saveCategories(); await this.saveCategories();
} }
@@ -249,6 +304,16 @@ export class MetaEngine extends EventEmitter {
async removeCategory(category: string): Promise<void> { async removeCategory(category: string): Promise<void> {
const normalizedCategory = normalizeTaxonomyTerm(category); const normalizedCategory = normalizeTaxonomyTerm(category);
if (this.categories.delete(normalizedCategory)) { if (this.categories.delete(normalizedCategory)) {
const currentMetadata = this.projectMetadata;
if (currentMetadata?.categorySettings?.[normalizedCategory]) {
const nextSettings = { ...currentMetadata.categorySettings };
delete nextSettings[normalizedCategory];
this.projectMetadata = normalizeProjectMetadata({
...currentMetadata,
categorySettings: nextSettings,
});
await this.saveProjectMetadata();
}
this.emit('categoriesChanged', await this.getCategories()); this.emit('categoriesChanged', await this.getCategories());
await this.saveCategories(); await this.saveCategories();
} }
@@ -477,10 +542,31 @@ 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) {
const mergedSettings = normalizeCategorySettings(this.projectMetadata.categorySettings);
let metadataChanged = false;
for (const category of this.categories) {
if (!mergedSettings[category]) {
mergedSettings[category] = { renderInLists: true, showTitle: true };
metadataChanged = true;
}
}
if (metadataChanged) {
this.projectMetadata = normalizeProjectMetadata({
...this.projectMetadata,
categorySettings: mergedSettings,
});
await this.saveProjectMetadata();
}
}
this.initialized = true; this.initialized = true;
console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`); console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`);
} }

View File

@@ -14,6 +14,12 @@ export interface TemplatePostEntry {
id: string; id: string;
title: string; title: string;
content: string; content: string;
show_title: boolean;
}
export interface CategoryRenderSettings {
renderInLists: boolean;
showTitle: boolean;
} }
export interface DayBlockContext { export interface DayBlockContext {
@@ -635,8 +641,24 @@ export class PageRenderer {
pico_stylesheet_href?: string; pico_stylesheet_href?: string;
html_theme_attribute?: string; html_theme_attribute?: string;
pagination?: PaginationContext; pagination?: PaginationContext;
categorySettings?: Record<string, CategoryRenderSettings>;
}, },
): PostListTemplateContext { ): PostListTemplateContext {
const shouldShowListTitle = (post: PostData): boolean => {
const categories = Array.isArray(post.categories) ? post.categories : [];
if (categories.length === 0) {
return true;
}
const settings = options.categorySettings ?? {};
const hasAnyNoTitleCategory = categories.some((category) => settings[category]?.showTitle === false);
if (hasAnyNoTitleCategory) {
return false;
}
return true;
};
const dayBlocks: DayBlockContext[] = []; const dayBlocks: DayBlockContext[] = [];
if (!options.archiveGrouping) { if (!options.archiveGrouping) {
@@ -648,6 +670,7 @@ export class PageRenderer {
id: post.id, id: post.id,
title: post.title, title: post.title,
content: post.content, content: post.content,
show_title: shouldShowListTitle(post),
})), })),
}); });
} else { } else {
@@ -672,6 +695,7 @@ export class PageRenderer {
id: post.id, id: post.id,
title: post.title, title: post.title,
content: post.content, content: post.content,
show_title: shouldShowListTitle(post),
}); });
} }
@@ -786,6 +810,7 @@ export class PageRenderer {
pico_stylesheet_href?: string; pico_stylesheet_href?: string;
html_theme_attribute?: string; html_theme_attribute?: string;
pagination?: PaginationContext; pagination?: PaginationContext;
categorySettings?: Record<string, CategoryRenderSettings>;
}, },
postEngine?: PostEngineContract, postEngine?: PostEngineContract,
): Promise<string> { ): Promise<string> {
@@ -820,6 +845,7 @@ export class PageRenderer {
id: renderablePost.id, id: renderablePost.id,
title: renderablePost.title, title: renderablePost.title,
content: renderablePost.content, content: renderablePost.content,
show_title: false,
}, },
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug), canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath), canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),

View File

@@ -4,7 +4,7 @@ import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { eq, and, desc, gte, lte, like, inArray, ne } from 'drizzle-orm'; import { eq, and, desc, gte, lte, like, inArray, ne, sql } from 'drizzle-orm';
import { app } from 'electron'; import { app } from 'electron';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { posts, Post, NewPost, postLinks } from '../database/schema'; import { posts, Post, NewPost, postLinks } from '../database/schema';
@@ -54,6 +54,7 @@ export interface PostFilter {
status?: 'draft' | 'published' | 'archived'; status?: 'draft' | 'published' | 'archived';
tags?: string[]; tags?: string[];
categories?: string[]; categories?: string[];
excludeCategories?: string[];
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
year?: number; year?: number;
@@ -736,6 +737,28 @@ export class PostEngine extends EventEmitter {
conditions.push(lte(posts.createdAt, endOfMonth)); conditions.push(lte(posts.createdAt, endOfMonth));
} }
if (filter.categories && filter.categories.length > 0) {
const includePredicates = filter.categories.map((category) =>
sql`exists (
select 1
from json_each(${posts.categories}) as included_category
where included_category.value = ${category}
)`
);
conditions.push(sql`(${sql.join(includePredicates, sql` OR `)})`);
}
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
const excludePredicates = filter.excludeCategories.map((category) =>
sql`exists (
select 1
from json_each(${posts.categories}) as excluded_category
where excluded_category.value = ${category}
)`
);
conditions.push(sql`NOT (${sql.join(excludePredicates, sql` OR `)})`);
}
const dbPosts = await db const dbPosts = await db
.select() .select()
.from(posts) .from(posts)
@@ -749,17 +772,12 @@ export class PostEngine extends EventEmitter {
// Use DB data directly instead of reading from filesystem // Use DB data directly instead of reading from filesystem
const postData = this.dbRowToPostData(dbPost, dbPost.content || ''); const postData = this.dbRowToPostData(dbPost, dbPost.content || '');
// Client-side filtering for tags/categories (JSON array) // Client-side filtering for tags only (category filtering is done in SQL)
if (filter.tags && filter.tags.length > 0) { if (filter.tags && filter.tags.length > 0) {
const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag)); const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag));
if (!hasAllTags) continue; if (!hasAllTags) continue;
} }
if (filter.categories && filter.categories.length > 0) {
const hasAnyCategory = filter.categories.some(cat => postData.categories.includes(cat));
if (!hasAnyCategory) continue;
}
result.push(postData); result.push(postData);
} }

View File

@@ -14,6 +14,7 @@ import {
clampMaxPostsPerPage, clampMaxPostsPerPage,
parseRoutePagination, parseRoutePagination,
resolvePageTitle, resolvePageTitle,
type CategoryRenderSettings,
type HtmlRewriteContext, type HtmlRewriteContext,
type MediaEngineContract, type MediaEngineContract,
type PostMediaEngineContract, type PostMediaEngineContract,
@@ -170,6 +171,8 @@ export class PreviewServer {
} }
const metadata = await this.settingsEngine.getProjectMetadata(); const metadata = await this.settingsEngine.getProjectMetadata();
const categorySettings = this.resolveCategorySettings(metadata);
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
const language = metadata?.mainLanguage?.trim() || 'en'; const language = metadata?.mainLanguage?.trim() || 'en';
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription); const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage); const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
@@ -187,7 +190,7 @@ export class PreviewServer {
language, language,
picoStylesheetHref, picoStylesheetHref,
htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined, htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined,
}); }, categorySettings, listExcludedCategories);
this.respond(res, 200, stylePreviewHtml); this.respond(res, 200, stylePreviewHtml);
return; return;
} }
@@ -215,7 +218,7 @@ export class PreviewServer {
language, language,
picoStylesheetHref, picoStylesheetHref,
htmlThemeAttribute: undefined, htmlThemeAttribute: undefined,
}); }, categorySettings, listExcludedCategories);
if (!result) { if (!result) {
const notFoundHtml = await this.pageRenderer.renderNotFound({ const notFoundHtml = await this.pageRenderer.renderNotFound({
page_title: '404 Not Found', page_title: '404 Not Found',
@@ -239,6 +242,8 @@ export class PreviewServer {
maxPostsPerPage: number, maxPostsPerPage: number,
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string | null> { ): Promise<string | null> {
const routePagination = parseRoutePagination(pathname); const routePagination = parseRoutePagination(pathname);
if (!routePagination) { if (!routePagination) {
@@ -311,13 +316,14 @@ export class PreviewServer {
} }
if (pagedPathname === '/') { if (pagedPathname === '/') {
const result = await this.loadPublishedSnapshotsPage({ status: 'published' }, pageOptions); const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions);
return this.pageRenderer.renderPostList(result.posts, rewriteContext, { return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
archiveGrouping: true, archiveGrouping: true,
routeKind: 'date', routeKind: 'date',
archiveContext: { kind: 'root' }, archiveContext: { kind: 'root' },
basePathname: pagedPathname, basePathname: pagedPathname,
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
categorySettings,
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
@@ -328,13 +334,14 @@ export class PreviewServer {
const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/); const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/);
if (tagMatch) { if (tagMatch) {
const tag = tagMatch[1]; const tag = tagMatch[1];
const result = await this.loadPublishedSnapshotsPage({ status: 'published', tags: [tag] }, pageOptions); const result = await this.loadPublishedSnapshotsPage({ status: 'published', tags: [tag], 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: 'tag', name: tag }, archiveContext: { kind: 'tag', name: tag },
basePathname: pagedPathname, basePathname: pagedPathname,
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
categorySettings,
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
@@ -345,13 +352,14 @@ 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 result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category] }, 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: category },
basePathname: pagedPathname, basePathname: pagedPathname,
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
categorySettings,
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
@@ -381,13 +389,17 @@ export class PreviewServer {
const year = Number(dayMatch[1]); const year = Number(dayMatch[1]);
const month = Number(dayMatch[2]); const month = Number(dayMatch[2]);
const day = Number(dayMatch[3]); const day = Number(dayMatch[3]);
const result = await this.loadPostsForDayPage(year, month, day, pageOptions); const result = await this.loadPostsForDayPage(year, month, day, {
...pageOptions,
excludeCategories: listExcludedCategories,
});
return this.pageRenderer.renderPostList(result.posts, rewriteContext, { return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
archiveGrouping: true, archiveGrouping: true,
routeKind: 'date', routeKind: 'date',
archiveContext: { kind: 'day', year, month, day }, archiveContext: { kind: 'day', year, month, day },
basePathname: pagedPathname, basePathname: pagedPathname,
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
categorySettings,
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
@@ -400,13 +412,14 @@ export class PreviewServer {
const year = Number(monthMatch[1]); const year = Number(monthMatch[1]);
const month = Number(monthMatch[2]); const month = Number(monthMatch[2]);
if (month < 1 || month > 12) return null; if (month < 1 || month > 12) return null;
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1 }, pageOptions); const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions);
return this.pageRenderer.renderPostList(result.posts, rewriteContext, { return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
archiveGrouping: true, archiveGrouping: true,
routeKind: 'date', routeKind: 'date',
archiveContext: { kind: 'month', year, month }, archiveContext: { kind: 'month', year, month },
basePathname: pagedPathname, basePathname: pagedPathname,
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
categorySettings,
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
@@ -417,13 +430,14 @@ export class PreviewServer {
const yearMatch = pagedPathname.match(/^\/(\d{4})$/); const yearMatch = pagedPathname.match(/^\/(\d{4})$/);
if (yearMatch) { if (yearMatch) {
const year = Number(yearMatch[1]); const year = Number(yearMatch[1]);
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year }, pageOptions); const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, excludeCategories: listExcludedCategories }, pageOptions);
return this.pageRenderer.renderPostList(result.posts, rewriteContext, { return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
archiveGrouping: true, archiveGrouping: true,
routeKind: 'date', routeKind: 'date',
archiveContext: { kind: 'year', year }, archiveContext: { kind: 'year', year },
basePathname: pagedPathname, basePathname: pagedPathname,
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
categorySettings,
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
@@ -451,8 +465,10 @@ export class PreviewServer {
private async renderStylePreview( private async renderStylePreview(
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string> { ): Promise<string> {
const result = await this.loadPublishedSnapshotsPage({ status: 'published' }, { const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, {
maxPostsPerPage: 10, maxPostsPerPage: 10,
page: 1, page: 1,
}); });
@@ -472,6 +488,7 @@ export class PreviewServer {
archiveContext: { kind: 'root' }, archiveContext: { kind: 'root' },
basePathname: '/__style-preview', basePathname: '/__style-preview',
pagination: { page: 1, maxPostsPerPage: 10, totalPosts: result.totalPosts }, pagination: { page: 1, maxPostsPerPage: 10, totalPosts: result.totalPosts },
categorySettings,
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
@@ -497,7 +514,7 @@ export class PreviewServer {
year: number, year: number,
month: number, month: number,
day: number, day: number,
pagination?: { maxPostsPerPage: number; page?: number }, pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<PostData[]> { ): Promise<PostData[]> {
const result = await this.loadPostsForDayPage(year, month, day, pagination); const result = await this.loadPostsForDayPage(year, month, day, pagination);
return result.posts; return result.posts;
@@ -507,7 +524,7 @@ export class PreviewServer {
year: number, year: number,
month: number, month: number,
day: number, day: number,
pagination?: { maxPostsPerPage: number; page?: number }, pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<{ posts: PostData[]; totalPosts: number }> { ): Promise<{ posts: PostData[]; totalPosts: number }> {
if (month < 1 || month > 12 || day < 1 || day > 31) { if (month < 1 || month > 12 || day < 1 || day > 31) {
return { posts: [], totalPosts: 0 }; return { posts: [], totalPosts: 0 };
@@ -518,6 +535,7 @@ export class PreviewServer {
const result = await this.loadPublishedSnapshotsPage({ const result = await this.loadPublishedSnapshotsPage({
status: 'published', status: 'published',
excludeCategories: pagination?.excludeCategories,
startDate, startDate,
endDate, endDate,
}, pagination); }, pagination);
@@ -560,7 +578,7 @@ export class PreviewServer {
private async loadPublishedSnapshots( private async loadPublishedSnapshots(
filter: PostFilter, filter: PostFilter,
pagination?: { maxPostsPerPage: number; page?: number }, pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<PostData[]> { ): Promise<PostData[]> {
const result = await this.loadPublishedSnapshotsPage(filter, pagination); const result = await this.loadPublishedSnapshotsPage(filter, pagination);
return result.posts; return result.posts;
@@ -568,7 +586,7 @@ export class PreviewServer {
private paginateSnapshots( private paginateSnapshots(
snapshots: PostData[], snapshots: PostData[],
pagination?: { maxPostsPerPage: number; page?: number }, pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): { posts: PostData[]; totalPosts: number } { ): { posts: PostData[]; totalPosts: number } {
const totalPosts = snapshots.length; const totalPosts = snapshots.length;
@@ -590,7 +608,7 @@ export class PreviewServer {
private async loadPublishedSnapshotsPage( private async loadPublishedSnapshotsPage(
filter: PostFilter, filter: PostFilter,
pagination?: { maxPostsPerPage: number; page?: number }, pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
): Promise<{ posts: PostData[]; totalPosts: number }> { ): Promise<{ posts: PostData[]; totalPosts: number }> {
if (filter.status && filter.status !== 'published') { if (filter.status && filter.status !== 'published') {
return { posts: [], totalPosts: 0 }; return { posts: [], totalPosts: 0 };
@@ -600,10 +618,12 @@ export class PreviewServer {
const publishedCandidates = await this.postEngine.getPostsFiltered({ const publishedCandidates = await this.postEngine.getPostsFiltered({
...baseFilter, ...baseFilter,
status: 'published', status: 'published',
excludeCategories: filter.excludeCategories,
}); });
const draftCandidates = await this.postEngine.getPostsFiltered({ const draftCandidates = await this.postEngine.getPostsFiltered({
...baseFilter, ...baseFilter,
status: 'draft', status: 'draft',
excludeCategories: filter.excludeCategories,
}); });
const snapshotCandidates = await Promise.all([ const snapshotCandidates = await Promise.all([
@@ -759,6 +779,41 @@ 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 },
};
const rawSettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings;
if (!rawSettings || typeof rawSettings !== 'object') {
return defaults;
}
const mergedSettings: Record<string, CategoryRenderSettings> = { ...defaults };
for (const [category, rawValue] of Object.entries(rawSettings as Record<string, unknown>)) {
if (!rawValue || typeof rawValue !== 'object') {
continue;
}
const typedRawValue = rawValue as Record<string, unknown>;
mergedSettings[category] = {
renderInLists: typedRawValue.renderInLists !== false,
showTitle: typedRawValue.showTitle !== false,
};
}
return mergedSettings;
}
private resolveListExcludedCategories(categorySettings: Record<string, CategoryRenderSettings>): string[] {
return Object.entries(categorySettings)
.filter(([, settings]) => settings.renderInLists === false)
.map(([category]) => category);
}
private respond(res: ServerResponse, status: number, body: string): void { private respond(res: ServerResponse, status: number, body: string): void {
res.statusCode = status; res.statusCode = status;
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');

View File

@@ -37,13 +37,23 @@
<aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside> <aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside>
<div class="archive-day-posts"> <div class="archive-day-posts">
{% for post in day_block.posts %} {% for post in day_block.posts %}
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}</div> <div class="post">
{% if post.show_title %}
<h2 class="post-title">{{ post.title }}</h2>
{% endif %}
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}
</div>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
{% else %} {% else %}
{% for post in day_block.posts %} {% for post in day_block.posts %}
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}</div> <div class="post">
{% if post.show_title %}
<h2 class="post-title">{{ post.title }}</h2>
{% endif %}
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}
</div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@@ -65,6 +65,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
maxPostsPerPage: metadata?.maxPostsPerPage, maxPostsPerPage: metadata?.maxPostsPerPage,
language, language,
pageTitle, pageTitle,
picoTheme: metadata?.picoTheme,
categorySettings: (metadata as any)?.categorySettings,
}; };
const runSectionTask = async ( const runSectionTask = async (

View File

@@ -863,7 +863,7 @@ export function registerIpcHandlers(): void {
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 }) => { 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 }> }) => {
const engine = getMetaEngine(); const engine = getMetaEngine();
await engine.updateProjectMetadata(updates); await engine.updateProjectMetadata(updates);
return engine.getProjectMetadata(); return engine.getProjectMetadata();

View File

@@ -158,7 +158,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; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./shared/picoThemes').PicoThemeName }) => 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; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
}, },
// Tag Management (advanced tag operations) // Tag Management (advanced tag operations)

View File

@@ -43,6 +43,12 @@ export interface ProjectMetadata {
defaultAuthor?: string; defaultAuthor?: string;
maxPostsPerPage?: number; maxPostsPerPage?: number;
picoTheme?: import('./picoThemes').PicoThemeName; picoTheme?: import('./picoThemes').PicoThemeName;
categorySettings?: Record<string, CategoryRenderSettings>;
}
export interface CategoryRenderSettings {
renderInLists: boolean;
showTitle: boolean;
} }
export interface ProjectData { export interface ProjectData {
@@ -529,7 +535,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 }) => 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>;
}; };
tags: { tags: {
getAll: () => Promise<TagData[]>; getAll: () => Promise<TagData[]>;

View File

@@ -353,8 +353,8 @@
.category-item { .category-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 12px;
padding: 6px 10px; padding: 8px 10px;
background-color: var(--vscode-badge-background); background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground); color: var(--vscode-badge-foreground);
border-radius: 4px; border-radius: 4px;
@@ -363,6 +363,25 @@
.category-name { .category-name {
font-weight: 500; font-weight: 500;
min-width: 140px;
}
.category-settings-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.category-setting-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.category-setting-toggle input[type="checkbox"] {
margin: 0;
} }
.category-remove { .category-remove {

View File

@@ -27,6 +27,11 @@ interface Credentials {
sshKeyPath: string; sshKeyPath: string;
} }
interface CategoryRenderSettings {
renderInLists: boolean;
showTitle: boolean;
}
const defaultCredentials: Credentials = { const defaultCredentials: Credentials = {
ftpHost: '', ftpHost: '',
ftpUser: '', ftpUser: '',
@@ -46,6 +51,13 @@ 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> = {
article: { renderInLists: true, showTitle: true },
picture: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
};
// Standard categories that cannot be deleted // Standard categories that cannot be deleted
const PROTECTED_CATEGORIES = ['article', 'aside', 'page', 'picture']; const PROTECTED_CATEGORIES = ['article', 'aside', 'page', 'picture'];
@@ -115,6 +127,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 [newCategoryInput, setNewCategoryInput] = useState(''); const [newCategoryInput, setNewCategoryInput] = useState('');
// AI Assistant settings // AI Assistant settings
@@ -165,6 +178,20 @@ export const SettingsView: React.FC = () => {
? metadata.maxPostsPerPage ? metadata.maxPostsPerPage
: 50; : 50;
setProjectMaxPostsPerPage(maxPostsPerPage); setProjectMaxPostsPerPage(maxPostsPerPage);
const incomingCategorySettings = (metadata as any)?.categorySettings as Record<string, CategoryRenderSettings> | undefined;
setCategorySettings((current) => {
const merged = { ...DEFAULT_CATEGORY_SETTINGS, ...current };
if (incomingCategorySettings && typeof incomingCategorySettings === 'object') {
for (const [category, settings] of Object.entries(incomingCategorySettings)) {
merged[category] = {
renderInLists: settings?.renderInLists !== false,
showTitle: settings?.showTitle !== false,
};
}
}
return merged;
});
}); });
} }
}, [activeProject]); }, [activeProject]);
@@ -182,9 +209,19 @@ 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) => {
const next = { ...DEFAULT_CATEGORY_SETTINGS, ...current };
for (const category of categories) {
if (!next[category]) {
next[category] = { renderInLists: true, showTitle: true };
}
}
return next;
});
} 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);
} }
// Load AI settings // Load AI settings
@@ -266,6 +303,7 @@ export const SettingsView: React.FC = () => {
mainLanguage: projectMainLanguage, mainLanguage: 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,
}); });
} }
showToast.success('Project settings saved'); showToast.success('Project settings saved');
@@ -537,6 +575,12 @@ export const SettingsView: React.FC = () => {
if (updatedCategories) { if (updatedCategories) {
setPostCategories(updatedCategories); setPostCategories(updatedCategories);
} }
const nextSettings = {
...categorySettings,
[trimmed]: categorySettings[trimmed] || { renderInLists: true, showTitle: true },
};
setCategorySettings(nextSettings);
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
setNewCategoryInput(''); setNewCategoryInput('');
showToast.success(`Category "${trimmed}" added`); showToast.success(`Category "${trimmed}" added`);
} catch (error) { } catch (error) {
@@ -562,6 +606,10 @@ export const SettingsView: React.FC = () => {
if (updatedCategories) { if (updatedCategories) {
setPostCategories(updatedCategories); setPostCategories(updatedCategories);
} }
const nextSettings = { ...categorySettings };
delete nextSettings[categoryToRemove];
setCategorySettings(nextSettings);
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
showToast.success(`Category "${categoryToRemove}" removed`); showToast.success(`Category "${categoryToRemove}" removed`);
} catch (error) { } catch (error) {
console.error('Failed to remove category:', error); console.error('Failed to remove category:', error);
@@ -585,6 +633,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 };
setCategorySettings(defaults);
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults });
showToast.success('Categories reset to defaults'); showToast.success('Categories reset to defaults');
} catch (error) { } catch (error) {
console.error('Failed to reset categories:', error); console.error('Failed to reset categories:', error);
@@ -592,6 +643,29 @@ export const SettingsView: React.FC = () => {
} }
}; };
const handleCategorySettingToggle = async (
category: string,
field: keyof CategoryRenderSettings,
value: boolean,
) => {
const nextSettings: Record<string, CategoryRenderSettings> = {
...categorySettings,
[category]: {
...(categorySettings[category] || { renderInLists: true, showTitle: true }),
[field]: value,
},
};
setCategorySettings(nextSettings);
try {
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
} catch (error) {
console.error('Failed to update category settings:', error);
showToast.error('Failed to update category settings');
}
};
const renderContentSettings = () => ( const renderContentSettings = () => (
<SettingSection <SettingSection
id="settings-section-content" id="settings-section-content"
@@ -602,9 +676,32 @@ export const SettingsView: React.FC = () => {
<div className="categories-list"> <div className="categories-list">
{postCategories.map((cat) => { {postCategories.map((cat) => {
const isProtected = PROTECTED_CATEGORIES.includes(cat); const isProtected = PROTECTED_CATEGORIES.includes(cat);
const setting = categorySettings[cat] || { renderInLists: true, showTitle: true };
return ( return (
<div key={cat} className="category-item"> <div key={cat} className="category-item">
<span className="category-name">{cat}{isProtected && ' (standard)'}</span> <span className="category-name">{cat}{isProtected && ' (standard)'}</span>
<div className="category-settings-controls">
<label className="category-setting-toggle" htmlFor={`category-${cat}-render-in-lists`}>
<input
id={`category-${cat}-render-in-lists`}
aria-label={`${cat} render in lists`}
type="checkbox"
checked={setting.renderInLists}
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
/>
<span>Render in lists</span>
</label>
<label className="category-setting-toggle" htmlFor={`category-${cat}-show-title`}>
<input
id={`category-${cat}-show-title`}
aria-label={`${cat} show titles`}
type="checkbox"
checked={setting.showTitle}
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
/>
<span>Show titles</span>
</label>
</div>
{!isProtected && ( {!isProtected && (
<button <button
className="category-remove" className="category-remove"

View File

@@ -423,10 +423,10 @@ describe('MetaEngine', () => {
}); });
const metadata = await metaEngine.getProjectMetadata(); const metadata = await metaEngine.getProjectMetadata();
expect(metadata).toEqual({ expect(metadata).toEqual(expect.objectContaining({
name: 'My Blog', name: 'My Blog',
description: 'A personal blog about technology', description: 'A personal blog about technology',
}); }));
}); });
it('should update project name only', async () => { it('should update project name only', async () => {
@@ -593,6 +593,70 @@ describe('MetaEngine', () => {
expect(parsed.picoTheme).toBe('slate'); expect(parsed.picoTheme).toBe('slate');
}); });
it('should apply default category settings for standard categories', async () => {
await metaEngine.setProjectMetadata({
name: 'Category Defaults Project',
} as any);
const metadata = await metaEngine.getProjectMetadata() as any;
expect(metadata.categorySettings).toEqual(
expect.objectContaining({
article: { renderInLists: true, showTitle: true },
picture: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
})
);
});
it('should persist category settings to project.json', async () => {
await metaEngine.setProjectMetadata({
name: 'Persisted Category Settings',
categorySettings: {
article: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
custom: { renderInLists: false, showTitle: true },
},
} as any);
const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`);
const content = mockFiles.get(projectPath);
const parsed = JSON.parse(content!);
expect(parsed.categorySettings).toEqual(
expect.objectContaining({
custom: { renderInLists: false, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
})
);
});
it('should merge missing category settings with defaults when loading from filesystem', async () => {
const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`);
mockFiles.set(projectPath, JSON.stringify({
name: 'Loaded Project',
categorySettings: {
custom: { renderInLists: false, showTitle: false },
},
}));
await metaEngine.loadProjectMetadata();
const metadata = await metaEngine.getProjectMetadata() as any;
expect(metadata.categorySettings).toEqual(
expect.objectContaining({
custom: { renderInLists: false, showTitle: false },
article: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
})
);
});
it('should load picoTheme from filesystem', async () => { it('should load picoTheme from filesystem', async () => {
const metaDir = metaEngine.getMetaDir(); const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`); const projectPath = normalizePath(`${metaDir}/project.json`);
@@ -691,10 +755,10 @@ describe('MetaEngine', () => {
description: 'Testing events', description: 'Testing events',
}); });
expect(handler).toHaveBeenCalledWith({ expect(handler).toHaveBeenCalledWith(expect.objectContaining({
name: 'Event Test', name: 'Event Test',
description: 'Testing events', description: 'Testing events',
}); }));
}); });
it('should clear project metadata when project context changes', () => { it('should clear project metadata when project context changes', () => {

View File

@@ -65,6 +65,10 @@ function makeEngine(posts: PostData[]): PostEngineLike {
result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category))); result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
} }
if ((filter as any).excludeCategories && (filter as any).excludeCategories.length > 0) {
result = result.filter((post) => !(filter as any).excludeCategories.some((category: string) => post.categories.includes(category)));
}
if (filter.year !== undefined) { if (filter.year !== undefined) {
result = result.filter((post) => post.createdAt.getUTCFullYear() === filter.year); result = result.filter((post) => post.createdAt.getUTCFullYear() === filter.year);
} }
@@ -519,6 +523,108 @@ describe('PreviewServer', () => {
expect(secondPageHtml).toContain('<h1 class="archive-heading">news - 1.1.2020 - 2.1.2020</h1>'); expect(secondPageHtml).toContain('<h1 class="archive-heading">news - 1.1.2020 - 2.1.2020</h1>');
}); });
it('filters out categories disabled for list rendering on list routes', async () => {
const posts = [
makePost({ id: 'list-1', slug: 'list-1', title: 'List Included', categories: ['article'], createdAt: new Date('2025-02-05T10:00:00.000Z') }),
makePost({ id: 'list-2', slug: 'list-2', title: 'List Excluded', categories: ['page'], createdAt: new Date('2025-02-04T10:00:00.000Z') }),
];
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return {
maxPostsPerPage: 50,
categorySettings: {
article: { renderInLists: true, showTitle: true },
page: { renderInLists: false, showTitle: true },
},
};
},
} as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const html = await (await fetch(`${server.getBaseUrl()}/`)).text();
expect(html).toContain('List Included');
expect(html).not.toContain('List Excluded');
});
it('suppresses all list category titles when any assigned category has showTitle disabled', async () => {
const posts = [
makePost({
id: 'ct-1',
slug: 'ct-1',
title: 'Category Title Test',
categories: ['aside', 'article'],
content: 'Body without markdown headings',
createdAt: new Date('2025-02-05T10:00:00.000Z'),
}),
];
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return {
maxPostsPerPage: 50,
categorySettings: {
article: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
},
};
},
} as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const html = await (await fetch(`${server.getBaseUrl()}/`)).text();
expect(html).not.toContain('<h2 class="post-title">Category Title Test</h2>');
});
it('renders post title in list when category titles are enabled', async () => {
const posts = [
makePost({
id: 'pt-1',
slug: 'pt-1',
title: 'Article Title',
categories: ['article'],
content: 'Body without markdown headings',
createdAt: new Date('2025-02-06T10:00:00.000Z'),
}),
];
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return {
maxPostsPerPage: 50,
categorySettings: {
article: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
},
};
},
} as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const html = await (await fetch(`${server.getBaseUrl()}/`)).text();
expect(html).toContain('<h2 class="post-title">Article Title</h2>');
expect(html).not.toContain('<h2 class="post-category-title">article</h2>');
});
it('supports tag, category, and page-slug routes', async () => { it('supports tag, category, and page-slug routes', async () => {
const tagged = makePost({ id: 'tag1', title: 'Tagged', slug: 'tagged', tags: ['dev'] }); const tagged = makePost({ id: 'tag1', title: 'Tagged', slug: 'tagged', tags: ['dev'] });
const categorized = makePost({ id: 'cat1', title: 'Categorized', slug: 'categorized', categories: ['news'] }); const categorized = makePost({ id: 'cat1', title: 'Categorized', slug: 'categorized', categories: ['news'] });
@@ -982,7 +1088,7 @@ describe('PreviewServer', () => {
slug: 'published-slug', slug: 'published-slug',
content: '# Published content only', content: '# Published content only',
tags: ['published-tag'], tags: ['published-tag'],
categories: ['page'], categories: ['article'],
createdAt: new Date('2025-02-14T10:00:00.000Z'), createdAt: new Date('2025-02-14T10:00:00.000Z'),
}); });

View File

@@ -43,7 +43,16 @@ describe('SettingsView Diff Preferences', () => {
meta: { meta: {
...(window as any).electronAPI?.meta, ...(window as any).electronAPI?.meta,
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']), getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75, publicUrl: 'https://example.com' }), getProjectMetadata: vi.fn().mockResolvedValue({
maxPostsPerPage: 75,
publicUrl: 'https://example.com',
categorySettings: {
article: { renderInLists: true, showTitle: true },
picture: { renderInLists: true, showTitle: true },
aside: { renderInLists: true, showTitle: false },
page: { renderInLists: false, showTitle: true },
},
}),
updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12, publicUrl: 'https://example.com' }), updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12, publicUrl: 'https://example.com' }),
}, },
chat: { chat: {
@@ -107,4 +116,35 @@ describe('SettingsView Diff Preferences', () => {
expect.objectContaining({ publicUrl: 'https://example.com' }) expect.objectContaining({ publicUrl: 'https://example.com' })
); );
}); });
it('renders category settings checkboxes with required defaults', async () => {
render(<SettingsView />);
const asideShowTitle = await screen.findByLabelText(/aside show titles/i);
const asideRenderInLists = screen.getByLabelText(/aside render in lists/i);
const pageRenderInLists = screen.getByLabelText(/page render in lists/i);
const articleShowTitle = screen.getByLabelText(/article show titles/i);
expect((asideShowTitle as HTMLInputElement).checked).toBe(false);
expect((asideRenderInLists as HTMLInputElement).checked).toBe(true);
expect((pageRenderInLists as HTMLInputElement).checked).toBe(false);
expect((articleShowTitle as HTMLInputElement).checked).toBe(true);
});
it('persists category settings changes via project metadata update', async () => {
render(<SettingsView />);
const pageRenderInLists = await screen.findByLabelText(/page render in lists/i);
fireEvent.click(pageRenderInLists);
await new Promise((resolve) => setTimeout(resolve, 0));
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
expect.objectContaining({
categorySettings: expect.objectContaining({
page: expect.objectContaining({ renderInLists: true, showTitle: true }),
}),
})
);
});
}); });