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_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<string, CategoryRenderSettings>;
sections?: BlogGenerationSection[];
}
@@ -81,6 +85,30 @@ function clampMaxPostsPerPage(value: unknown): number {
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 {
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<string, PostData>();
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<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);
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<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
): Promise<number> {
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<number> {
@@ -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<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
): Promise<number> {
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<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
): Promise<number> {
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<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
): Promise<number> {
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<string, CategoryRenderSettings>,
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,
});

View File

@@ -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<string, CategoryRenderSettings>; // 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<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.
*
@@ -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<void> {
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}`);

View File

@@ -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<string, CategoryRenderSettings>;
},
): 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<string, CategoryRenderSettings>;
},
postEngine?: PostEngineContract,
): Promise<string> {
@@ -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),

View File

@@ -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);
}

View File

@@ -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<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string | null> {
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<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string> {
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<PostData[]> {
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<PostData[]> {
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<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 {
res.statusCode = status;
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>
<div class="archive-day-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 %}
</div>
</section>
{% else %}
{% 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 %}
{% endif %}