feat: categories have settings for filtering and titles
This commit is contained in:
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +542,30 @@ 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}`);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user