chore: more refactorings and optimizations

This commit is contained in:
2026-02-22 09:54:42 +01:00
parent 011f318710
commit 03657e7a84
11 changed files with 485 additions and 78 deletions

View File

@@ -15,7 +15,7 @@ import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '..
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import { loadPublishedGenerationSets } from './GenerationPostSnapshotService';
import { buildSitemapAndFeeds } from './GenerationSitemapFeedService';
import { buildSitemapAndFeeds, collectSitemapArchiveMetadata } from './GenerationSitemapFeedService';
import { buildTargetedValidationPlan, planMissingValidationPaths } from './ValidationApplyPlannerService';
import { compareSitemapToHtml } from './SiteValidationDiffService';
import {
@@ -218,30 +218,57 @@ export class BlogGenerationEngine {
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
onProgress(5, 'Building sitemap XML...');
const {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
urls,
sitemapXml,
rssXml,
atomXml,
feedPosts,
} = buildSitemapAndFeeds({
baseUrl: options.baseUrl,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: true,
});
let allTags = new Set<string>();
let allCategories = new Set<string>();
let yearMonths = new Map<string, Date>();
let years = new Map<number, Date>();
let yearMonthDays = new Map<string, Date>();
let urls: string[] = [];
let sitemapXml = '';
let rssXml = '';
let atomXml = '';
let feedPosts: PostData[] = [];
onProgress(8, 'Building RSS and Atom feeds...');
if (includeCore) {
onProgress(5, 'Building sitemap XML...');
const sitemapAndFeedResult = buildSitemapAndFeeds({
baseUrl: options.baseUrl,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: true,
});
allTags = sitemapAndFeedResult.allTags;
allCategories = sitemapAndFeedResult.allCategories;
yearMonths = sitemapAndFeedResult.yearMonths;
years = sitemapAndFeedResult.years;
yearMonthDays = sitemapAndFeedResult.yearMonthDays;
urls = sitemapAndFeedResult.urls;
sitemapXml = sitemapAndFeedResult.sitemapXml;
rssXml = sitemapAndFeedResult.rssXml;
atomXml = sitemapAndFeedResult.atomXml;
feedPosts = sitemapAndFeedResult.feedPosts;
onProgress(8, 'Building RSS and Atom feeds...');
} else if (includeCategory || includeTag || includeDate) {
const archiveMetadata = collectSitemapArchiveMetadata({
baseUrl: options.baseUrl,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
});
allTags = archiveMetadata.allTags;
allCategories = archiveMetadata.allCategories;
yearMonths = archiveMetadata.yearMonths;
years = archiveMetadata.years;
yearMonthDays = archiveMetadata.yearMonthDays;
feedPosts = archiveMetadata.feedPosts;
}
const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true });
@@ -321,11 +348,16 @@ export class BlogGenerationEngine {
},
});
const knownOutputDirectories = new Set<string>();
const generatedHashCache = new Map<string, string | null>();
const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
projectId,
htmlDir,
urlPath,
content,
knownDirectories: knownOutputDirectories,
hashCache: generatedHashCache,
});
let pagesGenerated = 0;

View File

@@ -33,6 +33,7 @@ export async function writeFileIfHashChanged(params: {
filePath: string;
relativePath: string;
content: string;
hashCache?: Map<string, string | null>;
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
computeHash?: (content: string) => string;
@@ -42,13 +43,21 @@ export async function writeFileIfHashChanged(params: {
const hashFn = params.computeHash ?? computeContentHash;
const hash = hashFn(params.content);
const previousHash = await getHash(params.projectId, params.relativePath);
let previousHash: string | null;
if (params.hashCache && params.hashCache.has(params.relativePath)) {
previousHash = params.hashCache.get(params.relativePath) ?? null;
} else {
previousHash = await getHash(params.projectId, params.relativePath);
params.hashCache?.set(params.relativePath, previousHash);
}
if (previousHash === hash) {
return false;
}
await fs.writeFile(params.filePath, params.content, 'utf-8');
await setHash(params.projectId, params.relativePath, hash);
params.hashCache?.set(params.relativePath, hash);
return true;
}
@@ -57,6 +66,9 @@ export async function writeHtmlPage(params: {
htmlDir: string;
urlPath: string;
content: string;
knownDirectories?: Set<string>;
hashCache?: Map<string, string | null>;
ensureDirectory?: (dirPath: string) => Promise<void>;
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
computeHash?: (content: string) => string;
@@ -66,14 +78,26 @@ export async function writeHtmlPage(params: {
? path.join(params.htmlDir, normalizedPath, 'index.html')
: path.join(params.htmlDir, 'index.html');
const relativePath = normalizedPath ? `${normalizedPath}/index.html` : 'index.html';
const directoryPath = path.dirname(filePath);
const ensureDirectory = params.ensureDirectory ?? (async (dirPath: string) => {
await fs.mkdir(dirPath, { recursive: true });
});
await fs.mkdir(path.dirname(filePath), { recursive: true });
if (params.knownDirectories) {
if (!params.knownDirectories.has(directoryPath)) {
await ensureDirectory(directoryPath);
params.knownDirectories.add(directoryPath);
}
} else {
await ensureDirectory(directoryPath);
}
return writeFileIfHashChanged({
projectId: params.projectId,
filePath,
relativePath,
content: params.content,
hashCache: params.hashCache,
getGeneratedFileHash: params.getGeneratedFileHash,
setGeneratedFileHash: params.setGeneratedFileHash,
computeHash: params.computeHash,

View File

@@ -1,4 +1,5 @@
import type { CategoryRenderSettings } from './PageRenderer';
import { buildCanonicalPostPath } from './PageRenderer';
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import type { PostData } from './PostEngine';
@@ -15,6 +16,7 @@ interface RenderContext {
};
metadata?: ProjectMetadata | null;
menu?: MenuDocument;
skipContextSetup?: boolean;
maxPostsPerPage?: number;
}
@@ -89,6 +91,10 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
projectDescription: params.options.projectDescription,
};
params.engines.postEngine.setProjectContext(projectContext.projectId, projectContext.dataDir);
params.engines.mediaEngine.setProjectContext?.(projectContext.projectId, projectContext.dataDir, projectContext.dataDir);
params.engines.postMediaEngine.setProjectContext(projectContext.projectId);
const mediaItemsPromiseCache = new Map<string, Promise<unknown[]>>();
const postsByFilterPromiseCache = new Map<string, Promise<PostData[]>>();
const publishedSnapshotByIdPromiseCache = new Map<string, Promise<PostData | null>>();
@@ -201,12 +207,42 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
getActiveProjectContext: async () => projectContext,
});
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
const canonicalPostPathBySlug = new Map<string, string>();
for (const post of params.publishedPostsForLookup) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
}
const canonicalMediaPathBySourcePath = new Map<string, string>();
const mediaItems = await cachedMediaEngine.getAllMedia();
for (const media of mediaItems as Array<{ createdAt: Date; filename: string; originalName: string }>) {
const year = media.createdAt.getFullYear();
const month = String(media.createdAt.getMonth() + 1).padStart(2, '0');
const canonicalPath = `/media/${year}/${month}/${media.filename}`;
const originalNameKey = `media/${year}/${month}/${media.originalName}`.toLowerCase();
const filenameKey = `media/${year}/${month}/${media.filename}`.toLowerCase();
canonicalMediaPathBySourcePath.set(originalNameKey, canonicalPath);
canonicalMediaPathBySourcePath.set(filenameKey, canonicalPath);
}
return {
canonicalPostPathBySlug,
canonicalMediaPathBySourcePath,
};
})();
return createGenerationRouteRenderer({
renderWithContext: (pathname, context) => previewServer.renderRouteForContext(pathname, context),
renderWithContext: async (pathname, context) => previewServer.renderRouteForContext(pathname, {
...context,
htmlRewriteContext: await htmlRewriteContextPromise,
}),
context: {
projectContext,
metadata,
menu,
skipContextSetup: true,
maxPostsPerPage: params.maxPostsPerPage,
},
});

View File

@@ -32,6 +32,18 @@ export interface SitemapFeedBuildResult {
feedPosts: PostData[];
}
export interface SitemapArchiveMetadata {
allTags: Set<string>;
allCategories: Set<string>;
yearMonths: Map<string, Date>;
years: Map<number, Date>;
yearMonthDays: Map<string, Date>;
feedPosts: PostData[];
postUrls: Array<{ loc: string; lastmod: string }>;
pageUrls: Array<{ loc: string; lastmod: string }>;
latestPostUpdatedAt: string;
}
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
if (post.createdAt instanceof Date) {
return post.createdAt;
@@ -142,19 +154,19 @@ function escapeCdata(value: string): string {
return value.replace(/]]>/g, ']]]]><![CDATA[>');
}
export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): SitemapFeedBuildResult {
export function collectSitemapArchiveMetadata(params: {
baseUrl: string;
maxPostsPerPage: number;
publishedPosts: PostData[];
publishedListPosts: PostData[];
}): SitemapArchiveMetadata {
const {
baseUrl,
projectName,
projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex,
includeFeeds,
} = params;
const now = new Date().toISOString();
const allTags = new Set<string>();
const allCategories = new Set<string>();
const yearMonths = new Map<string, Date>();
@@ -206,7 +218,53 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
}
}
const now = new Date().toISOString();
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
const feedPosts = publishedListPosts.slice(0, maxPostsPerPage);
return {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
feedPosts,
postUrls,
pageUrls,
latestPostUpdatedAt,
};
}
export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): SitemapFeedBuildResult {
const {
baseUrl,
projectName,
projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex,
includeFeeds,
} = params;
const archiveMetadata = collectSitemapArchiveMetadata({
baseUrl,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
});
const {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
postUrls,
pageUrls,
latestPostUpdatedAt,
feedPosts,
} = archiveMetadata;
const urls: string[] = [];
urls.push(buildSitemapUrl(`${baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
@@ -259,7 +317,6 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
'',
].join('\n');
const feedPosts = publishedListPosts.slice(0, maxPostsPerPage);
if (!includeFeeds) {
return {
allTags,

View File

@@ -155,6 +155,10 @@ function normalizeCategorySettings(value: unknown): Record<string, CategoryRende
);
}
function isJsonParseError(error: unknown): boolean {
return error instanceof SyntaxError;
}
/**
* MetaEngine manages project metadata like available tags and categories.
*
@@ -447,6 +451,11 @@ export class MetaEngine extends EventEmitter {
const parsed = JSON.parse(content) as ProjectMetadata;
this.projectMetadata = normalizeProjectMetadata(parsed);
} catch (error) {
if (isJsonParseError(error)) {
console.warn('[MetaEngine] Failed to parse project metadata JSON, using null metadata:', error);
this.projectMetadata = null;
return;
}
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load project metadata:', error);
throw error;
@@ -466,6 +475,10 @@ export class MetaEngine extends EventEmitter {
const parsed = JSON.parse(content) as Record<string, unknown>;
return normalizeCategoryMetadata(parsed);
} catch (error) {
if (isJsonParseError(error)) {
console.warn('[MetaEngine] Failed to parse category metadata JSON, using default metadata merge:', error);
return null;
}
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load category metadata:', error);
throw error;
@@ -490,6 +503,11 @@ export class MetaEngine extends EventEmitter {
}
}
} catch (error) {
if (isJsonParseError(error)) {
console.warn('[MetaEngine] Failed to parse categories JSON, treating as empty and rebuilding from DB/defaults:', error);
this.categories.clear();
return;
}
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load categories:', error);
throw error;
@@ -653,6 +671,18 @@ export class MetaEngine extends EventEmitter {
// Handle project metadata
if (projectMetadataFileExists) {
await this.loadProjectMetadata();
if (!this.projectMetadata) {
const projectData = await this.fetchProjectFromDatabase();
if (!projectData) {
throw new Error(`Project not found in database: ${this.currentProjectId}`);
}
this.projectMetadata = {
name: projectData.name,
description: projectData.description || undefined,
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
};
await this.saveProjectMetadata();
}
if (this.projectMetadata?.dataPath !== undefined) {
const { dataPath: _dataPath, ...metadataWithoutDataPath } = this.projectMetadata;
this.projectMetadata = metadataWithoutDataPath;

View File

@@ -166,6 +166,8 @@ export class PreviewServer {
projectContext: ActiveProjectContext;
metadata?: ProjectMetadata | null;
menu?: MenuDocument;
htmlRewriteContext?: HtmlRewriteContext;
skipContextSetup?: boolean;
maxPostsPerPage?: number;
requestTheme?: string | null;
htmlThemeAttribute?: string;

View File

@@ -25,6 +25,8 @@ export interface SharedRouteRenderOptions {
projectContext: SharedActiveProjectContext;
metadata?: ProjectMetadata | null;
menu?: MenuDocument;
htmlRewriteContext?: HtmlRewriteContext;
skipContextSetup?: boolean;
maxPostsPerPage?: number;
requestTheme?: string | null;
htmlThemeAttribute?: string;
@@ -267,11 +269,13 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
options: SharedRouteRenderOptions,
services: SharedRouteRenderServices<TCategoryMetadata>,
): Promise<string | null> {
services.postEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.mediaEngine.setProjectContext?.(options.projectContext.projectId, options.projectContext.dataDir, options.projectContext.dataDir);
services.postMediaEngine.setProjectContext(options.projectContext.projectId);
services.settingsEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.menuEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
if (!options.skipContextSetup) {
services.postEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.mediaEngine.setProjectContext?.(options.projectContext.projectId, options.projectContext.dataDir, options.projectContext.dataDir);
services.postMediaEngine.setProjectContext(options.projectContext.projectId);
services.settingsEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.menuEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
}
let metadata = options.metadata;
if (metadata === undefined) {
@@ -292,7 +296,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
const appliedTheme = sanitizePicoTheme(options.requestTheme)
?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
const htmlRewriteContext = await services.buildHtmlRewriteContext();
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {