feat: first cut at the full renderer
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as crypto from 'crypto';
|
||||
import { getDatabase } from '../database';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { getGeneratedFileHash, setGeneratedFileHash } from '../database/generatedFileHashStore';
|
||||
import { getPostEngine, type PostData } from './PostEngine';
|
||||
import { getMediaEngine, type MediaData } from './MediaEngine';
|
||||
import { getPostMediaEngine } from './PostMediaEngine';
|
||||
import {
|
||||
PageRenderer,
|
||||
PREVIEW_ASSETS,
|
||||
PREVIEW_IMAGE_ASSETS,
|
||||
buildCanonicalPostPath,
|
||||
type HtmlRewriteContext,
|
||||
} from './PageRenderer';
|
||||
|
||||
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||
@@ -15,8 +25,13 @@ export interface BlogGenerationOptions {
|
||||
dataDir: string;
|
||||
baseUrl: string;
|
||||
maxPostsPerPage?: number;
|
||||
language?: string;
|
||||
pageTitle?: string;
|
||||
sections?: BlogGenerationSection[];
|
||||
}
|
||||
|
||||
export type BlogGenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date';
|
||||
|
||||
export interface BlogGenerationResult {
|
||||
path: string;
|
||||
urlCount: number;
|
||||
@@ -25,6 +40,7 @@ export interface BlogGenerationResult {
|
||||
tagCount: number;
|
||||
categoryCount: number;
|
||||
archiveCount: number;
|
||||
pagesGenerated: number;
|
||||
feeds: {
|
||||
rssPath: string;
|
||||
atomPath: string;
|
||||
@@ -145,52 +161,46 @@ function computeContentHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
async function getHashSettingValue(key: string): Promise<string | null> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database client not available');
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: 'SELECT value FROM settings WHERE key = ? LIMIT 1',
|
||||
args: [key],
|
||||
});
|
||||
|
||||
if (!result.rows[0] || typeof result.rows[0].value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return result.rows[0].value;
|
||||
}
|
||||
|
||||
async function setHashSettingValue(key: string, value: string): Promise<void> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) {
|
||||
throw new Error('Database client not available');
|
||||
}
|
||||
|
||||
await client.execute({
|
||||
sql: 'INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
|
||||
args: [key, value, new Date()],
|
||||
});
|
||||
}
|
||||
|
||||
async function writeFileIfHashChanged(filePath: string, content: string, hashKey: string): Promise<boolean> {
|
||||
async function writeFileIfHashChanged(projectId: string, filePath: string, relativePath: string, content: string): Promise<boolean> {
|
||||
const hash = computeContentHash(content);
|
||||
const previousHash = await getHashSettingValue(hashKey);
|
||||
const previousHash = await getGeneratedFileHash(projectId, relativePath);
|
||||
if (previousHash === hash) {
|
||||
return false;
|
||||
}
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
await setHashSettingValue(hashKey, hash);
|
||||
await setGeneratedFileHash(projectId, relativePath, hash);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function writeHtmlPage(projectId: string, htmlDir: string, urlPath: string, content: string): Promise<boolean> {
|
||||
const normalizedPath = urlPath.replace(/^\//, '');
|
||||
const filePath = normalizedPath
|
||||
? path.join(htmlDir, normalizedPath, 'index.html')
|
||||
: path.join(htmlDir, 'index.html');
|
||||
const relativePath = normalizedPath ? `${normalizedPath}/index.html` : 'index.html';
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
return writeFileIfHashChanged(projectId, filePath, relativePath, content);
|
||||
}
|
||||
|
||||
export class BlogGenerationEngine {
|
||||
private readonly postEngine = getPostEngine();
|
||||
private readonly mediaEngine = getMediaEngine();
|
||||
private readonly postMediaEngine = getPostMediaEngine();
|
||||
|
||||
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
|
||||
onProgress(0, 'Loading posts...');
|
||||
|
||||
const selectedSections = new Set<BlogGenerationSection>(
|
||||
options.sections && options.sections.length > 0
|
||||
? options.sections
|
||||
: ['core', 'single', 'category', 'tag', 'date'],
|
||||
);
|
||||
const includeCore = selectedSections.has('core');
|
||||
const includeSingle = selectedSections.has('single');
|
||||
const includeCategory = selectedSections.has('category');
|
||||
const includeTag = selectedSections.has('tag');
|
||||
const includeDate = selectedSections.has('date');
|
||||
|
||||
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
|
||||
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
|
||||
@@ -219,7 +229,7 @@ export class BlogGenerationEngine {
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
const feedPosts = publishedPosts.slice(0, maxPostsPerPage);
|
||||
|
||||
onProgress(10, `Found ${publishedPosts.length} published posts`);
|
||||
onProgress(3, `Found ${publishedPosts.length} published posts`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const allTags = new Set<string>();
|
||||
@@ -258,7 +268,7 @@ export class BlogGenerationEngine {
|
||||
|
||||
const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now;
|
||||
|
||||
onProgress(40, 'Building sitemap XML...');
|
||||
onProgress(5, 'Building sitemap XML...');
|
||||
|
||||
const urls: string[] = [];
|
||||
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
|
||||
@@ -266,7 +276,6 @@ export class BlogGenerationEngine {
|
||||
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
|
||||
}
|
||||
|
||||
onProgress(55, 'Adding archive pages...');
|
||||
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
|
||||
urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||
}
|
||||
@@ -277,17 +286,15 @@ export class BlogGenerationEngine {
|
||||
urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
||||
}
|
||||
|
||||
onProgress(70, 'Adding category pages...');
|
||||
for (const category of Array.from(allCategories).sort()) {
|
||||
urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||
}
|
||||
|
||||
onProgress(80, 'Adding tag pages...');
|
||||
for (const tag of Array.from(allTags).sort()) {
|
||||
urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||
}
|
||||
|
||||
onProgress(85, 'Building RSS and Atom feeds...');
|
||||
onProgress(8, 'Building RSS and Atom feeds...');
|
||||
|
||||
const sitemapXml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
@@ -382,22 +389,93 @@ export class BlogGenerationEngine {
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
onProgress(92, 'Writing sitemap and feeds...');
|
||||
|
||||
const htmlDir = path.join(options.dataDir, 'html');
|
||||
await fs.mkdir(htmlDir, { recursive: true });
|
||||
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
|
||||
const rssPath = path.join(htmlDir, 'rss.xml');
|
||||
const atomPath = path.join(htmlDir, 'atom.xml');
|
||||
const hashKeyPrefix = `project:${options.projectId}:generation-hash`;
|
||||
|
||||
const [sitemapWritten, rssWritten, atomWritten] = await Promise.all([
|
||||
writeFileIfHashChanged(sitemapPath, sitemapXml, `${hashKeyPrefix}:sitemap.xml`),
|
||||
writeFileIfHashChanged(rssPath, rssXml, `${hashKeyPrefix}:rss.xml`),
|
||||
writeFileIfHashChanged(atomPath, atomXml, `${hashKeyPrefix}:atom.xml`),
|
||||
]);
|
||||
const estimatedUnitsBySection = this.estimateGenerationUnitsBySection(
|
||||
publishedPosts,
|
||||
allCategories,
|
||||
allTags,
|
||||
years,
|
||||
yearMonths,
|
||||
yearMonthDays,
|
||||
maxPostsPerPage,
|
||||
);
|
||||
const totalEstimatedUnits = [
|
||||
includeCore ? estimatedUnitsBySection.core : 0,
|
||||
includeSingle ? estimatedUnitsBySection.single : 0,
|
||||
includeCategory ? estimatedUnitsBySection.category : 0,
|
||||
includeTag ? estimatedUnitsBySection.tag : 0,
|
||||
includeDate ? estimatedUnitsBySection.date : 0,
|
||||
].reduce((sum, value) => sum + value, 0);
|
||||
let completedUnits = 0;
|
||||
|
||||
onProgress(100, `Sitemap and feeds generated (${feedPosts.length} feed posts)`);
|
||||
const reportUnitProgress = (message: string) => {
|
||||
if (totalEstimatedUnits <= 0) {
|
||||
return;
|
||||
}
|
||||
completedUnits += 1;
|
||||
const progress = 10 + Math.floor((completedUnits / totalEstimatedUnits) * 85);
|
||||
onProgress(Math.min(95, progress), message);
|
||||
};
|
||||
|
||||
let sitemapWritten = false;
|
||||
let rssWritten = false;
|
||||
let atomWritten = false;
|
||||
|
||||
if (includeCore) {
|
||||
onProgress(10, 'Writing sitemap and feeds...');
|
||||
sitemapWritten = await writeFileIfHashChanged(options.projectId, sitemapPath, 'sitemap.xml', sitemapXml);
|
||||
reportUnitProgress('Sitemap written');
|
||||
rssWritten = await writeFileIfHashChanged(options.projectId, rssPath, 'rss.xml', rssXml);
|
||||
reportUnitProgress('RSS feed written');
|
||||
atomWritten = await writeFileIfHashChanged(options.projectId, atomPath, 'atom.xml', atomXml);
|
||||
reportUnitProgress('Atom feed written');
|
||||
|
||||
onProgress(15, 'Copying assets...');
|
||||
await this.copyAssets(htmlDir);
|
||||
reportUnitProgress('Assets copied');
|
||||
}
|
||||
|
||||
const pageTitle = options.pageTitle || options.projectName;
|
||||
const language = options.language || 'en';
|
||||
const pageContext = { page_title: pageTitle, language };
|
||||
|
||||
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine);
|
||||
const rewriteContext = this.buildHtmlRewriteContext(publishedPosts);
|
||||
|
||||
let pagesGenerated = 0;
|
||||
|
||||
if (includeCore) {
|
||||
onProgress(20, 'Generating root pages...');
|
||||
pagesGenerated += await this.generateRootPages(options.projectId, publishedPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
|
||||
pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress);
|
||||
}
|
||||
|
||||
if (includeSingle) {
|
||||
onProgress(35, 'Generating single post pages...');
|
||||
pagesGenerated += await this.generateSinglePostPages(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress);
|
||||
}
|
||||
|
||||
if (includeCategory) {
|
||||
onProgress(50, 'Generating category pages...');
|
||||
pagesGenerated += await this.generateCategoryPages(options.projectId, publishedPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
|
||||
}
|
||||
|
||||
if (includeTag) {
|
||||
onProgress(65, 'Generating tag pages...');
|
||||
pagesGenerated += await this.generateTagPages(options.projectId, publishedPosts, allTags, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
|
||||
}
|
||||
|
||||
if (includeDate) {
|
||||
onProgress(80, 'Generating date archive pages...');
|
||||
pagesGenerated += await this.generateDateArchivePages(options.projectId, publishedPosts, years, yearMonths, yearMonthDays, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
|
||||
}
|
||||
|
||||
onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`);
|
||||
|
||||
return {
|
||||
path: sitemapPath,
|
||||
@@ -407,6 +485,7 @@ export class BlogGenerationEngine {
|
||||
tagCount: allTags.size,
|
||||
categoryCount: allCategories.size,
|
||||
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
|
||||
pagesGenerated,
|
||||
feeds: {
|
||||
rssPath,
|
||||
atomPath,
|
||||
@@ -418,6 +497,395 @@ export class BlogGenerationEngine {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async generatePageRoutes(
|
||||
projectId: string,
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string },
|
||||
pageRenderer: PageRenderer,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
const pagePosts = posts.filter((post) => (post.categories || []).includes('page'));
|
||||
|
||||
for (const post of pagePosts) {
|
||||
const html = await pageRenderer.renderSinglePost(post, rewriteContext, pageContext);
|
||||
await writeHtmlPage(projectId, htmlDir, post.slug, html);
|
||||
count++;
|
||||
onPageGenerated(`Generated /${post.slug}`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private buildHtmlRewriteContext(publishedPosts: PostData[]): HtmlRewriteContext {
|
||||
const canonicalPostPathBySlug = new Map<string, string>();
|
||||
for (const post of publishedPosts) {
|
||||
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
|
||||
}
|
||||
|
||||
const canonicalMediaPathBySourcePath = new Map<string, string>();
|
||||
|
||||
return {
|
||||
canonicalPostPathBySlug,
|
||||
canonicalMediaPathBySourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
private async copyAssets(htmlDir: string): Promise<void> {
|
||||
const assetsDir = path.join(htmlDir, 'assets');
|
||||
const imagesDir = path.join(htmlDir, 'images');
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) {
|
||||
const sourcePath = require.resolve(definition.modulePath);
|
||||
const destPath = path.join(assetsDir, filename);
|
||||
const content = await readFile(sourcePath);
|
||||
await fs.writeFile(destPath, content);
|
||||
}
|
||||
|
||||
for (const [filename, definition] of Object.entries(PREVIEW_IMAGE_ASSETS)) {
|
||||
const sourcePath = require.resolve(definition.modulePath);
|
||||
const destPath = path.join(imagesDir, filename);
|
||||
const content = await readFile(sourcePath);
|
||||
await fs.writeFile(destPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateRootPages(
|
||||
projectId: string,
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string },
|
||||
pageRenderer: PageRenderer,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage));
|
||||
let count = 0;
|
||||
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
const pagePosts = posts.slice(offset, offset + maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
|
||||
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
archiveContext: { kind: 'root' },
|
||||
basePathname: '/',
|
||||
pagination: { page, maxPostsPerPage, totalPosts: posts.length },
|
||||
...pageContext,
|
||||
});
|
||||
|
||||
if (html) {
|
||||
const urlPath = page === 1 ? '' : `page/${page}`;
|
||||
await writeHtmlPage(projectId, htmlDir, urlPath, html);
|
||||
count++;
|
||||
onPageGenerated(urlPath ? `Generated /${urlPath}` : 'Generated /');
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async generateSinglePostPages(
|
||||
projectId: string,
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string },
|
||||
pageRenderer: PageRenderer,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for (const post of posts) {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const year = createdAt.getFullYear();
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||
|
||||
const html = await pageRenderer.renderSinglePost(post, rewriteContext, pageContext);
|
||||
const urlPath = `${year}/${month}/${day}/${post.slug}`;
|
||||
await writeHtmlPage(projectId, htmlDir, urlPath, html);
|
||||
count++;
|
||||
onPageGenerated(`Generated /${urlPath}`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async generateCategoryPages(
|
||||
projectId: string,
|
||||
posts: PostData[],
|
||||
allCategories: Set<string>,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string },
|
||||
pageRenderer: PageRenderer,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for (const category of Array.from(allCategories).sort()) {
|
||||
const categoryPosts = posts.filter((post) => (post.categories || []).includes(category));
|
||||
if (categoryPosts.length === 0) continue;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage));
|
||||
const encodedCategory = encodeURIComponent(category);
|
||||
const basePathname = `/category/${encodedCategory}`;
|
||||
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
const pagePosts = categoryPosts.slice(offset, offset + maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
|
||||
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'non-date',
|
||||
archiveContext: { kind: 'category', name: category },
|
||||
basePathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length },
|
||||
...pageContext,
|
||||
});
|
||||
|
||||
if (html) {
|
||||
const urlPath = page === 1
|
||||
? `category/${encodedCategory}`
|
||||
: `category/${encodedCategory}/page/${page}`;
|
||||
await writeHtmlPage(projectId, htmlDir, urlPath, html);
|
||||
count++;
|
||||
onPageGenerated(`Generated /${urlPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async generateTagPages(
|
||||
projectId: string,
|
||||
posts: PostData[],
|
||||
allTags: Set<string>,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string },
|
||||
pageRenderer: PageRenderer,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for (const tag of Array.from(allTags).sort()) {
|
||||
const tagPosts = posts.filter((post) => (post.tags || []).includes(tag));
|
||||
if (tagPosts.length === 0) continue;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(tagPosts.length / maxPostsPerPage));
|
||||
const encodedTag = encodeURIComponent(tag);
|
||||
const basePathname = `/tag/${encodedTag}`;
|
||||
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
const pagePosts = tagPosts.slice(offset, offset + maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
|
||||
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'non-date',
|
||||
archiveContext: { kind: 'tag', name: tag },
|
||||
basePathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: tagPosts.length },
|
||||
...pageContext,
|
||||
});
|
||||
|
||||
if (html) {
|
||||
const urlPath = page === 1
|
||||
? `tag/${encodedTag}`
|
||||
: `tag/${encodedTag}/page/${page}`;
|
||||
await writeHtmlPage(projectId, htmlDir, urlPath, html);
|
||||
count++;
|
||||
onPageGenerated(`Generated /${urlPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async generateDateArchivePages(
|
||||
projectId: string,
|
||||
posts: PostData[],
|
||||
yearsMap: Map<number, Date>,
|
||||
yearMonthsMap: Map<string, Date>,
|
||||
yearMonthDaysMap: Map<string, Date>,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string },
|
||||
pageRenderer: PageRenderer,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
let count = 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);
|
||||
count += await this.generatePaginatedListPages(
|
||||
projectId, yearPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated,
|
||||
`${year}`, `/${year}`, { kind: 'year', year }, 'date',
|
||||
);
|
||||
}
|
||||
|
||||
for (const [ym] of Array.from(yearMonthsMap.entries()).sort().reverse()) {
|
||||
const [yearStr, monthStr] = ym.split('/');
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr);
|
||||
const monthPosts = posts.filter((post) => {
|
||||
const d = resolvePostCreatedAt(post);
|
||||
return d.getFullYear() === year && (d.getMonth() + 1) === month;
|
||||
});
|
||||
count += await this.generatePaginatedListPages(
|
||||
projectId, monthPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated,
|
||||
ym, `/${ym}`, { kind: 'month', year, month }, 'date',
|
||||
);
|
||||
}
|
||||
|
||||
for (const [ymd] of Array.from(yearMonthDaysMap.entries()).sort().reverse()) {
|
||||
const [yearStr, monthStr, dayStr] = ymd.split('/');
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr);
|
||||
const day = Number(dayStr);
|
||||
const dayPosts = posts.filter((post) => {
|
||||
const d = resolvePostCreatedAt(post);
|
||||
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
|
||||
});
|
||||
count += await this.generatePaginatedListPages(
|
||||
projectId, dayPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated,
|
||||
ymd, `/${ymd}`, { kind: 'day', year, month, day }, 'date',
|
||||
);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async generatePaginatedListPages(
|
||||
projectId: string,
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
maxPostsPerPage: number,
|
||||
htmlDir: string,
|
||||
pageContext: { page_title: string; language: string },
|
||||
pageRenderer: PageRenderer,
|
||||
onPageGenerated: (message: string) => void,
|
||||
urlPrefix: string,
|
||||
basePathname: string,
|
||||
archiveContext: { kind: 'root' | 'year' | 'month' | 'day' | 'tag' | 'category'; name?: string; year?: number; month?: number; day?: number },
|
||||
routeKind: 'date' | 'non-date',
|
||||
): Promise<number> {
|
||||
if (posts.length === 0) return 0;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage));
|
||||
let count = 0;
|
||||
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
const pagePosts = posts.slice(offset, offset + maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
|
||||
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind,
|
||||
archiveContext,
|
||||
basePathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: posts.length },
|
||||
...pageContext,
|
||||
});
|
||||
|
||||
if (html) {
|
||||
const urlPath = page === 1
|
||||
? urlPrefix
|
||||
: `${urlPrefix}/page/${page}`;
|
||||
await writeHtmlPage(projectId, htmlDir, urlPath, html);
|
||||
count++;
|
||||
onPageGenerated(`Generated /${urlPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private estimateGenerationUnitsBySection(
|
||||
posts: PostData[],
|
||||
allCategories: Set<string>,
|
||||
allTags: Set<string>,
|
||||
yearsMap: Map<number, Date>,
|
||||
yearMonthsMap: Map<string, Date>,
|
||||
yearMonthDaysMap: Map<string, Date>,
|
||||
maxPostsPerPage: number,
|
||||
): Record<BlogGenerationSection, number> {
|
||||
const rootPages = this.countPaginatedPages(posts.length, maxPostsPerPage);
|
||||
const pageRoutes = posts.filter((post) => (post.categories || []).includes('page')).length;
|
||||
|
||||
const categoryPages = Array.from(allCategories).reduce((sum, category) => {
|
||||
const count = posts.filter((post) => (post.categories || []).includes(category)).length;
|
||||
return sum + this.countPaginatedPages(count, maxPostsPerPage);
|
||||
}, 0);
|
||||
|
||||
const tagPages = Array.from(allTags).reduce((sum, tag) => {
|
||||
const count = posts.filter((post) => (post.tags || []).includes(tag)).length;
|
||||
return sum + this.countPaginatedPages(count, maxPostsPerPage);
|
||||
}, 0);
|
||||
|
||||
let datePages = 0;
|
||||
|
||||
for (const [year] of yearsMap) {
|
||||
const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year);
|
||||
datePages += this.countPaginatedPages(yearPosts.length, maxPostsPerPage);
|
||||
}
|
||||
|
||||
for (const [ym] of yearMonthsMap) {
|
||||
const [yearStr, monthStr] = ym.split('/');
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr);
|
||||
const monthPosts = posts.filter((post) => {
|
||||
const d = resolvePostCreatedAt(post);
|
||||
return d.getFullYear() === year && (d.getMonth() + 1) === month;
|
||||
});
|
||||
datePages += this.countPaginatedPages(monthPosts.length, maxPostsPerPage);
|
||||
}
|
||||
|
||||
for (const [ymd] of yearMonthDaysMap) {
|
||||
const [yearStr, monthStr, dayStr] = ymd.split('/');
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr);
|
||||
const day = Number(dayStr);
|
||||
const dayPosts = posts.filter((post) => {
|
||||
const d = resolvePostCreatedAt(post);
|
||||
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
|
||||
});
|
||||
datePages += this.countPaginatedPages(dayPosts.length, maxPostsPerPage);
|
||||
}
|
||||
|
||||
return {
|
||||
core: 4 + rootPages + pageRoutes,
|
||||
single: posts.length,
|
||||
category: categoryPages,
|
||||
tag: tagPages,
|
||||
date: datePages,
|
||||
};
|
||||
}
|
||||
|
||||
private countPaginatedPages(totalPosts: number, maxPostsPerPage: number): number {
|
||||
if (totalPosts <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(1, Math.ceil(totalPosts / maxPostsPerPage));
|
||||
}
|
||||
}
|
||||
|
||||
let blogGenerationEngine: BlogGenerationEngine | null = null;
|
||||
|
||||
Reference in New Issue
Block a user