diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index e36b16d..261bc11 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -11,8 +11,10 @@ import { PREVIEW_ASSETS, PREVIEW_IMAGE_ASSETS, buildCanonicalPostPath, + type CategoryRenderSettings, type HtmlRewriteContext, } from './PageRenderer'; +import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes'; const DEFAULT_MAX_POSTS_PER_PAGE = 50; const MIN_MAX_POSTS_PER_PAGE = 1; @@ -27,6 +29,8 @@ export interface BlogGenerationOptions { maxPostsPerPage?: number; language?: string; pageTitle?: string; + picoTheme?: PicoThemeName; + categorySettings?: Record; sections?: BlogGenerationSection[]; } @@ -81,6 +85,30 @@ function clampMaxPostsPerPage(value: unknown): number { return normalized; } +function resolveCategorySettings( + value: Record | undefined, +): Record { + const defaults: Record = { + 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 { const year = createdAt.getFullYear(); const month = String(createdAt.getMonth() + 1).padStart(2, '0'); @@ -201,9 +229,22 @@ export class BlogGenerationEngine { const includeTag = selectedSections.has('tag'); 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 publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' }); 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( publishedCandidates.map(async (post) => { @@ -214,6 +255,15 @@ export class BlogGenerationEngine { const draftPublishedSnapshots = await Promise.all( 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(); for (const post of publishedSnapshots) { @@ -227,6 +277,17 @@ export class BlogGenerationEngine { const publishedPosts = Array.from(publishedPostById.values()) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const publishedListPostById = new Map(); + 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); onProgress(3, `Found ${publishedPosts.length} published posts`); @@ -240,14 +301,19 @@ export class BlogGenerationEngine { const postUrls: Array<{ loc: string; lastmod: string }> = []; 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 canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); const postUrl = `${options.baseUrl}${canonicalPath}`; const updatedAt = post.updatedAt; 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 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...'); @@ -396,7 +462,7 @@ export class BlogGenerationEngine { const atomPath = path.join(htmlDir, 'atom.xml'); const estimatedUnitsBySection = this.estimateGenerationUnitsBySection( - publishedPosts, + publishedListPosts, allCategories, allTags, years, @@ -442,7 +508,11 @@ export class BlogGenerationEngine { const pageTitle = options.pageTitle || options.projectName; 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 rewriteContext = this.buildHtmlRewriteContext(publishedPosts); @@ -451,7 +521,7 @@ export class BlogGenerationEngine { if (includeCore) { 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); } @@ -462,17 +532,17 @@ export class BlogGenerationEngine { if (includeCategory) { 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) { 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) { 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)`); @@ -561,8 +631,9 @@ export class BlogGenerationEngine { rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string }, + pageContext: { page_title: string; language: string; pico_stylesheet_href?: string }, pageRenderer: PageRenderer, + categorySettings: Record, onPageGenerated: (message: string) => void, ): Promise { const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage)); @@ -579,6 +650,7 @@ export class BlogGenerationEngine { archiveContext: { kind: 'root' }, basePathname: '/', pagination: { page, maxPostsPerPage, totalPosts: posts.length }, + categorySettings, ...pageContext, }); @@ -598,7 +670,7 @@ export class BlogGenerationEngine { posts: PostData[], rewriteContext: HtmlRewriteContext, htmlDir: string, - pageContext: { page_title: string; language: string }, + pageContext: { page_title: string; language: string; pico_stylesheet_href?: string }, pageRenderer: PageRenderer, onPageGenerated: (message: string) => void, ): Promise { @@ -627,8 +699,9 @@ export class BlogGenerationEngine { rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string }, + pageContext: { page_title: string; language: string; pico_stylesheet_href?: string }, pageRenderer: PageRenderer, + categorySettings: Record, onPageGenerated: (message: string) => void, ): Promise { let count = 0; @@ -652,6 +725,7 @@ export class BlogGenerationEngine { archiveContext: { kind: 'category', name: category }, basePathname, pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length }, + categorySettings, ...pageContext, }); @@ -676,8 +750,9 @@ export class BlogGenerationEngine { rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string }, + pageContext: { page_title: string; language: string; pico_stylesheet_href?: string }, pageRenderer: PageRenderer, + categorySettings: Record, onPageGenerated: (message: string) => void, ): Promise { let count = 0; @@ -701,6 +776,7 @@ export class BlogGenerationEngine { archiveContext: { kind: 'tag', name: tag }, basePathname, pagination: { page, maxPostsPerPage, totalPosts: tagPosts.length }, + categorySettings, ...pageContext, }); @@ -727,8 +803,9 @@ export class BlogGenerationEngine { rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string }, + pageContext: { page_title: string; language: string; pico_stylesheet_href?: string }, pageRenderer: PageRenderer, + categorySettings: Record, onPageGenerated: (message: string) => void, ): Promise { 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])) { const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year); 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', ); } @@ -750,7 +827,7 @@ export class BlogGenerationEngine { return d.getFullYear() === year && (d.getMonth() + 1) === month; }); 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', ); } @@ -765,7 +842,7 @@ export class BlogGenerationEngine { return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day; }); 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', ); } @@ -779,8 +856,9 @@ export class BlogGenerationEngine { rewriteContext: HtmlRewriteContext, maxPostsPerPage: number, htmlDir: string, - pageContext: { page_title: string; language: string }, + pageContext: { page_title: string; language: string; pico_stylesheet_href?: string }, pageRenderer: PageRenderer, + categorySettings: Record, onPageGenerated: (message: string) => void, urlPrefix: string, basePathname: string, @@ -803,6 +881,7 @@ export class BlogGenerationEngine { archiveContext, basePathname, pagination: { page, maxPostsPerPage, totalPosts: posts.length }, + categorySettings, ...pageContext, }); diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index cd416a0..e8a0fd1 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -24,6 +24,12 @@ 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 + categorySettings?: Record; // Per-category list rendering preferences +} + +export interface CategoryRenderSettings { + renderInLists: boolean; + showTitle: boolean; } const DEFAULT_MAX_POSTS_PER_PAGE = 50; @@ -64,11 +70,13 @@ 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); return { ...metadata, publicUrl, maxPostsPerPage, picoTheme, + categorySettings, }; } @@ -77,6 +85,38 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { */ export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page']; +export function getDefaultCategorySettings(): Record { + 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 { + const defaults = getDefaultCategorySettings(); + if (!value || typeof value !== 'object') { + return defaults; + } + + const normalized: Record = { ...defaults }; + for (const [rawCategory, rawSettings] of Object.entries(value as Record)) { + const category = normalizeTaxonomyTerm(rawCategory); + if (!category || !rawSettings || typeof rawSettings !== 'object') { + continue; + } + + const settings = rawSettings as Record; + normalized[category] = { + renderInLists: settings.renderInLists !== false, + showTitle: settings.showTitle !== false, + }; + } + + return normalized; +} + /** * MetaEngine manages project metadata like available tags and categories. * @@ -238,6 +278,21 @@ export class MetaEngine extends EventEmitter { const normalizedCategory = normalizeTaxonomyTerm(category); if (normalizedCategory && !this.categories.has(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()); await this.saveCategories(); } @@ -249,6 +304,16 @@ export class MetaEngine extends EventEmitter { async removeCategory(category: string): Promise { const normalizedCategory = normalizeTaxonomyTerm(category); 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()); await this.saveCategories(); } @@ -477,9 +542,30 @@ 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; + + 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; console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`); diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index fa2440e..a04edba 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -14,6 +14,12 @@ export interface TemplatePostEntry { id: string; title: string; content: string; + show_title: boolean; +} + +export interface CategoryRenderSettings { + renderInLists: boolean; + showTitle: boolean; } export interface DayBlockContext { @@ -635,8 +641,24 @@ export class PageRenderer { pico_stylesheet_href?: string; html_theme_attribute?: string; pagination?: PaginationContext; + categorySettings?: Record; }, ): 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[] = []; if (!options.archiveGrouping) { @@ -648,6 +670,7 @@ export class PageRenderer { id: post.id, title: post.title, content: post.content, + show_title: shouldShowListTitle(post), })), }); } else { @@ -672,6 +695,7 @@ export class PageRenderer { id: post.id, title: post.title, content: post.content, + show_title: shouldShowListTitle(post), }); } @@ -786,6 +810,7 @@ export class PageRenderer { pico_stylesheet_href?: string; html_theme_attribute?: string; pagination?: PaginationContext; + categorySettings?: Record; }, postEngine?: PostEngineContract, ): Promise { @@ -820,6 +845,7 @@ export class PageRenderer { id: renderablePost.id, title: renderablePost.title, content: renderablePost.content, + show_title: false, }, canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug), canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath), diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 62bce11..f64deb1 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -4,7 +4,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; 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 { getDatabase } from '../database'; import { posts, Post, NewPost, postLinks } from '../database/schema'; @@ -54,6 +54,7 @@ export interface PostFilter { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; + excludeCategories?: string[]; startDate?: Date; endDate?: Date; year?: number; @@ -736,6 +737,28 @@ export class PostEngine extends EventEmitter { 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 .select() .from(posts) @@ -749,17 +772,12 @@ export class PostEngine extends EventEmitter { // Use DB data directly instead of reading from filesystem 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) { const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag)); 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); } diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 4dfb9aa..d3c7b1a 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -14,6 +14,7 @@ import { clampMaxPostsPerPage, parseRoutePagination, resolvePageTitle, + type CategoryRenderSettings, type HtmlRewriteContext, type MediaEngineContract, type PostMediaEngineContract, @@ -170,6 +171,8 @@ export class PreviewServer { } const metadata = await this.settingsEngine.getProjectMetadata(); + const categorySettings = this.resolveCategorySettings(metadata); + const listExcludedCategories = this.resolveListExcludedCategories(categorySettings); const language = metadata?.mainLanguage?.trim() || 'en'; const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription); const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage); @@ -187,7 +190,7 @@ export class PreviewServer { language, picoStylesheetHref, htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined, - }); + }, categorySettings, listExcludedCategories); this.respond(res, 200, stylePreviewHtml); return; } @@ -215,7 +218,7 @@ export class PreviewServer { language, picoStylesheetHref, htmlThemeAttribute: undefined, - }); + }, categorySettings, listExcludedCategories); if (!result) { const notFoundHtml = await this.pageRenderer.renderNotFound({ page_title: '404 Not Found', @@ -239,6 +242,8 @@ export class PreviewServer { maxPostsPerPage: number, rewriteContext: HtmlRewriteContext, pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, + categorySettings: Record, + listExcludedCategories: string[], ): Promise { const routePagination = parseRoutePagination(pathname); if (!routePagination) { @@ -311,13 +316,14 @@ export class PreviewServer { } 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, { archiveGrouping: true, routeKind: 'date', archiveContext: { kind: 'root' }, basePathname: pagedPathname, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, pico_stylesheet_href: pageContext.picoStylesheetHref, @@ -328,13 +334,14 @@ export class PreviewServer { const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/); if (tagMatch) { 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, { archiveGrouping: true, routeKind: 'non-date', archiveContext: { kind: 'tag', name: tag }, basePathname: pagedPathname, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, pico_stylesheet_href: pageContext.picoStylesheetHref, @@ -345,13 +352,14 @@ export class PreviewServer { const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/); if (categoryMatch) { 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, { archiveGrouping: true, routeKind: 'non-date', archiveContext: { kind: 'category', name: category }, basePathname: pagedPathname, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, pico_stylesheet_href: pageContext.picoStylesheetHref, @@ -381,13 +389,17 @@ export class PreviewServer { const year = Number(dayMatch[1]); const month = Number(dayMatch[2]); 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, { archiveGrouping: true, routeKind: 'date', archiveContext: { kind: 'day', year, month, day }, basePathname: pagedPathname, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, pico_stylesheet_href: pageContext.picoStylesheetHref, @@ -400,13 +412,14 @@ export class PreviewServer { const year = Number(monthMatch[1]); const month = Number(monthMatch[2]); 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, { archiveGrouping: true, routeKind: 'date', archiveContext: { kind: 'month', year, month }, basePathname: pagedPathname, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, pico_stylesheet_href: pageContext.picoStylesheetHref, @@ -417,13 +430,14 @@ export class PreviewServer { const yearMatch = pagedPathname.match(/^\/(\d{4})$/); if (yearMatch) { 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, { archiveGrouping: true, routeKind: 'date', archiveContext: { kind: 'year', year }, basePathname: pagedPathname, pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, + categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, pico_stylesheet_href: pageContext.picoStylesheetHref, @@ -451,8 +465,10 @@ export class PreviewServer { private async renderStylePreview( rewriteContext: HtmlRewriteContext, pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, + categorySettings: Record, + listExcludedCategories: string[], ): Promise { - const result = await this.loadPublishedSnapshotsPage({ status: 'published' }, { + const result = await this.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, { maxPostsPerPage: 10, page: 1, }); @@ -472,6 +488,7 @@ export class PreviewServer { archiveContext: { kind: 'root' }, basePathname: '/__style-preview', pagination: { page: 1, maxPostsPerPage: 10, totalPosts: result.totalPosts }, + categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, pico_stylesheet_href: pageContext.picoStylesheetHref, @@ -497,7 +514,7 @@ export class PreviewServer { year: number, month: number, day: number, - pagination?: { maxPostsPerPage: number; page?: number }, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, ): Promise { const result = await this.loadPostsForDayPage(year, month, day, pagination); return result.posts; @@ -507,7 +524,7 @@ export class PreviewServer { year: number, month: number, day: number, - pagination?: { maxPostsPerPage: number; page?: number }, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, ): Promise<{ posts: PostData[]; totalPosts: number }> { if (month < 1 || month > 12 || day < 1 || day > 31) { return { posts: [], totalPosts: 0 }; @@ -518,6 +535,7 @@ export class PreviewServer { const result = await this.loadPublishedSnapshotsPage({ status: 'published', + excludeCategories: pagination?.excludeCategories, startDate, endDate, }, pagination); @@ -560,7 +578,7 @@ export class PreviewServer { private async loadPublishedSnapshots( filter: PostFilter, - pagination?: { maxPostsPerPage: number; page?: number }, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, ): Promise { const result = await this.loadPublishedSnapshotsPage(filter, pagination); return result.posts; @@ -568,7 +586,7 @@ export class PreviewServer { private paginateSnapshots( snapshots: PostData[], - pagination?: { maxPostsPerPage: number; page?: number }, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, ): { posts: PostData[]; totalPosts: number } { const totalPosts = snapshots.length; @@ -590,7 +608,7 @@ export class PreviewServer { private async loadPublishedSnapshotsPage( filter: PostFilter, - pagination?: { maxPostsPerPage: number; page?: number }, + pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] }, ): Promise<{ posts: PostData[]; totalPosts: number }> { if (filter.status && filter.status !== 'published') { return { posts: [], totalPosts: 0 }; @@ -600,10 +618,12 @@ export class PreviewServer { const publishedCandidates = await this.postEngine.getPostsFiltered({ ...baseFilter, status: 'published', + excludeCategories: filter.excludeCategories, }); const draftCandidates = await this.postEngine.getPostsFiltered({ ...baseFilter, status: 'draft', + excludeCategories: filter.excludeCategories, }); const snapshotCandidates = await Promise.all([ @@ -759,6 +779,41 @@ export class PreviewServer { } } + private resolveCategorySettings(metadata: ProjectMetadata | null): Record { + const defaults: Record = { + 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 = { ...defaults }; + for (const [category, rawValue] of Object.entries(rawSettings as Record)) { + if (!rawValue || typeof rawValue !== 'object') { + continue; + } + + const typedRawValue = rawValue as Record; + mergedSettings[category] = { + renderInLists: typedRawValue.renderInLists !== false, + showTitle: typedRawValue.showTitle !== false, + }; + } + + return mergedSettings; + } + + private resolveListExcludedCategories(categorySettings: Record): string[] { + return Object.entries(categorySettings) + .filter(([, settings]) => settings.renderInLists === false) + .map(([category]) => category); + } + private respond(res: ServerResponse, status: number, body: string): void { res.statusCode = status; res.setHeader('Content-Type', 'text/html; charset=utf-8'); diff --git a/src/main/engine/templates/post-list.liquid b/src/main/engine/templates/post-list.liquid index 6477bdf..83b986f 100644 --- a/src/main/engine/templates/post-list.liquid +++ b/src/main/engine/templates/post-list.liquid @@ -37,13 +37,23 @@
{% for post in day_block.posts %} -
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}
+
+ {% if post.show_title %} +

{{ post.title }}

+ {% endif %} + {{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }} +
{% endfor %}
{% else %} {% for post in day_block.posts %} -
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}
+
+ {% if post.show_title %} +

{{ post.title }}

+ {% endif %} + {{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }} +
{% endfor %} {% endif %} diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts index 95b651d..13531f2 100644 --- a/src/main/ipc/blogHandlers.ts +++ b/src/main/ipc/blogHandlers.ts @@ -65,6 +65,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { maxPostsPerPage: metadata?.maxPostsPerPage, language, pageTitle, + picoTheme: metadata?.picoTheme, + categorySettings: (metadata as any)?.categorySettings, }; const runSectionTask = async ( diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 0a4649b..d67d9f0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -863,7 +863,7 @@ export function registerIpcHandlers(): void { 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 }) => { const engine = getMetaEngine(); await engine.updateProjectMetadata(updates); return engine.getProjectMetadata(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 119f7ca..0d70f07 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -158,7 +158,7 @@ export const electronAPI: ElectronAPI = { syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), 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 }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), }, // Tag Management (advanced tag operations) diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 039feda..9feaa5b 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -43,6 +43,12 @@ export interface ProjectMetadata { defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; + categorySettings?: Record; +} + +export interface CategoryRenderSettings { + renderInLists: boolean; + showTitle: boolean; } export interface ProjectData { @@ -529,7 +535,7 @@ export interface ElectronAPI { syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; getProjectMetadata: () => Promise; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; categorySettings?: Record }) => Promise; }; tags: { getAll: () => Promise; diff --git a/src/renderer/components/SettingsView/SettingsView.css b/src/renderer/components/SettingsView/SettingsView.css index a11241e..b02ff53 100644 --- a/src/renderer/components/SettingsView/SettingsView.css +++ b/src/renderer/components/SettingsView/SettingsView.css @@ -353,8 +353,8 @@ .category-item { display: flex; align-items: center; - gap: 6px; - padding: 6px 10px; + gap: 12px; + padding: 8px 10px; background-color: var(--vscode-badge-background); color: var(--vscode-badge-foreground); border-radius: 4px; @@ -363,6 +363,25 @@ .category-name { 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 { diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 0ecbc3c..f98fcf6 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -27,6 +27,11 @@ interface Credentials { sshKeyPath: string; } +interface CategoryRenderSettings { + renderInLists: boolean; + showTitle: boolean; +} + const defaultCredentials: Credentials = { ftpHost: '', ftpUser: '', @@ -46,6 +51,13 @@ const SearchIcon = () => ( // Default post categories based on VISION.md const DEFAULT_POST_CATEGORIES = ['article', 'picture', 'aside', 'page']; +const DEFAULT_CATEGORY_SETTINGS: Record = { + 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 const PROTECTED_CATEGORIES = ['article', 'aside', 'page', 'picture']; @@ -115,6 +127,7 @@ export const SettingsView: React.FC = () => { // Post categories management const [postCategories, setPostCategories] = useState(DEFAULT_POST_CATEGORIES); + const [categorySettings, setCategorySettings] = useState>(DEFAULT_CATEGORY_SETTINGS); const [newCategoryInput, setNewCategoryInput] = useState(''); // AI Assistant settings @@ -165,6 +178,20 @@ export const SettingsView: React.FC = () => { ? metadata.maxPostsPerPage : 50; setProjectMaxPostsPerPage(maxPostsPerPage); + + const incomingCategorySettings = (metadata as any)?.categorySettings as Record | 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]); @@ -182,9 +209,19 @@ export const SettingsView: React.FC = () => { const categories = await window.electronAPI?.meta.getCategories(); if (categories && categories.length > 0) { 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 { // Initialize with defaults if no categories exist setPostCategories(DEFAULT_POST_CATEGORIES); + setCategorySettings(DEFAULT_CATEGORY_SETTINGS); } // Load AI settings @@ -266,6 +303,7 @@ export const SettingsView: React.FC = () => { mainLanguage: projectMainLanguage, defaultAuthor: projectDefaultAuthor.trim() || undefined, maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), + categorySettings, }); } showToast.success('Project settings saved'); @@ -537,6 +575,12 @@ export const SettingsView: React.FC = () => { if (updatedCategories) { setPostCategories(updatedCategories); } + const nextSettings = { + ...categorySettings, + [trimmed]: categorySettings[trimmed] || { renderInLists: true, showTitle: true }, + }; + setCategorySettings(nextSettings); + await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings }); setNewCategoryInput(''); showToast.success(`Category "${trimmed}" added`); } catch (error) { @@ -562,6 +606,10 @@ export const SettingsView: React.FC = () => { if (updatedCategories) { setPostCategories(updatedCategories); } + const nextSettings = { ...categorySettings }; + delete nextSettings[categoryToRemove]; + setCategorySettings(nextSettings); + await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings }); showToast.success(`Category "${categoryToRemove}" removed`); } catch (error) { console.error('Failed to remove category:', error); @@ -585,6 +633,9 @@ export const SettingsView: React.FC = () => { // Refresh the list const updatedCategories = await window.electronAPI?.meta.getCategories(); 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'); } catch (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 = { + ...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 = () => ( {
{postCategories.map((cat) => { const isProtected = PROTECTED_CATEGORIES.includes(cat); + const setting = categorySettings[cat] || { renderInLists: true, showTitle: true }; return (
{cat}{isProtected && ' (standard)'} +
+ + +
{!isProtected && (