chore: more refactorings and optimizations
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user