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;
|
||||
|
||||
812
src/main/engine/PageRenderer.ts
Normal file
812
src/main/engine/PageRenderer.ts
Normal file
@@ -0,0 +1,812 @@
|
||||
import path from 'node:path';
|
||||
import { marked } from 'marked';
|
||||
import { Liquid } from 'liquidjs';
|
||||
import type { MediaData } from './MediaEngine';
|
||||
import type { PostData } from './PostEngine';
|
||||
|
||||
export interface HtmlRewriteContext {
|
||||
canonicalPostPathBySlug: Map<string, string>;
|
||||
canonicalMediaPathBySourcePath: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface TemplatePostEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DayBlockContext {
|
||||
date_label: string;
|
||||
show_date_marker: boolean;
|
||||
show_separator: boolean;
|
||||
posts: TemplatePostEntry[];
|
||||
}
|
||||
|
||||
export interface PaginationContext {
|
||||
page: number;
|
||||
maxPostsPerPage: number;
|
||||
totalPosts: number;
|
||||
}
|
||||
|
||||
export type ArchiveRouteKind = 'date' | 'non-date';
|
||||
|
||||
export type DateArchiveContext = {
|
||||
kind: 'root' | 'year' | 'month' | 'day' | 'tag' | 'category';
|
||||
name?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
};
|
||||
|
||||
export interface PostListTemplateContext {
|
||||
page_title: string;
|
||||
language: string;
|
||||
is_date_archive: boolean;
|
||||
show_archive_range_heading: boolean;
|
||||
archive_context: {
|
||||
kind: 'root' | 'year' | 'month' | 'day' | 'tag' | 'category';
|
||||
name: string | null;
|
||||
year: number | null;
|
||||
month: number | null;
|
||||
day: number | null;
|
||||
} | null;
|
||||
min_date: { day: number; month: number; year: number } | null;
|
||||
max_date: { day: number; month: number; year: number } | null;
|
||||
is_list_page: boolean;
|
||||
is_first_page: boolean;
|
||||
is_last_page: boolean;
|
||||
has_prev_page: boolean;
|
||||
has_next_page: boolean;
|
||||
prev_page_href: string;
|
||||
next_page_href: string;
|
||||
canonical_post_path_by_slug: Record<string, string>;
|
||||
canonical_media_path_by_source_path: Record<string, string>;
|
||||
day_blocks: DayBlockContext[];
|
||||
}
|
||||
|
||||
export interface SinglePostTemplateContext {
|
||||
page_title: string;
|
||||
language: string;
|
||||
post: TemplatePostEntry;
|
||||
canonical_post_path_by_slug: Record<string, string>;
|
||||
canonical_media_path_by_source_path: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotFoundTemplateContext {
|
||||
page_title: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface RoutePagination {
|
||||
pathname: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface MediaEngineContract {
|
||||
getAllMedia: () => Promise<MediaData[]>;
|
||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||
}
|
||||
|
||||
export interface PostMediaEngineContract {
|
||||
getLinkedMediaDataForPost: (postId: string) => Promise<Array<{ media: MediaData }>>;
|
||||
setProjectContext: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export interface PostEngineContract {
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
}
|
||||
|
||||
export const PREVIEW_ASSETS = {
|
||||
'pico.min.css': {
|
||||
modulePath: '@picocss/pico/css/pico.min.css',
|
||||
contentType: 'text/css; charset=utf-8',
|
||||
},
|
||||
'lightbox.min.css': {
|
||||
modulePath: 'lightbox2/dist/css/lightbox.min.css',
|
||||
contentType: 'text/css; charset=utf-8',
|
||||
},
|
||||
'lightbox.min.js': {
|
||||
modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
|
||||
contentType: 'application/javascript; charset=utf-8',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const PREVIEW_IMAGE_ASSETS = {
|
||||
'prev.png': {
|
||||
modulePath: 'lightbox2/dist/images/prev.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
'next.png': {
|
||||
modulePath: 'lightbox2/dist/images/next.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
'close.png': {
|
||||
modulePath: 'lightbox2/dist/images/close.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
'loading.gif': {
|
||||
modulePath: 'lightbox2/dist/images/loading.gif',
|
||||
contentType: 'image/gif',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||
const MAX_MAX_POSTS_PER_PAGE = 500;
|
||||
|
||||
export function clampMaxPostsPerPage(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||
}
|
||||
|
||||
const normalized = Math.floor(value);
|
||||
if (normalized < MIN_MAX_POSTS_PER_PAGE) return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||
if (normalized > MAX_MAX_POSTS_PER_PAGE) return MAX_MAX_POSTS_PER_PAGE;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolvePageTitle(metadata: { description?: string; name?: string } | null, fallbackProjectName?: string, fallbackProjectDescription?: string): string {
|
||||
const candidate = metadata?.description?.trim();
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const metadataName = metadata?.name?.trim();
|
||||
if (metadataName) {
|
||||
return metadataName;
|
||||
}
|
||||
|
||||
const descriptionFallback = fallbackProjectDescription?.trim();
|
||||
if (descriptionFallback) {
|
||||
return descriptionFallback;
|
||||
}
|
||||
|
||||
const fallback = fallbackProjectName?.trim();
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return 'Blog Preview';
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function parseMacroParams(paramString: string | undefined): Record<string, string> {
|
||||
if (!paramString) return {};
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
const regex = /(\w+)=(?:["']([^"']*?)["']|([^\s\]]+))/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
|
||||
while ((match = regex.exec(paramString)) !== null) {
|
||||
params[match[1]] = match[2] !== undefined ? match[2] : match[3];
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function parseIntegerParam(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function normalizeMacroName(name: string): string {
|
||||
if (name === 'photo_album') {
|
||||
return 'photo_archive';
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
export function buildCanonicalMediaPath(media: MediaData): string {
|
||||
const year = media.createdAt.getFullYear();
|
||||
const month = String(media.createdAt.getMonth() + 1).padStart(2, '0');
|
||||
return `/media/${year}/${month}/${media.filename}`;
|
||||
}
|
||||
|
||||
export function isRenderableImage(media: MediaData): boolean {
|
||||
if (media.mimeType?.toLowerCase().startsWith('image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const extension = path.extname(media.filename).toLowerCase();
|
||||
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.avif'].includes(extension);
|
||||
}
|
||||
|
||||
export function buildPhotoArchiveBuckets(
|
||||
mediaItems: MediaData[],
|
||||
params: Record<string, string>,
|
||||
): Array<{ year: number; month: number; media: MediaData[] }> {
|
||||
const yearParam = parseIntegerParam(params.year);
|
||||
const monthParam = parseIntegerParam(params.month);
|
||||
|
||||
const filteredByDate = mediaItems.filter((media) => {
|
||||
const year = media.createdAt.getFullYear();
|
||||
const month = media.createdAt.getMonth() + 1;
|
||||
|
||||
if (yearParam !== null && year !== yearParam) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (monthParam !== null && month !== monthParam) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const buckets = new Map<string, { year: number; month: number; media: MediaData[] }>();
|
||||
for (const media of filteredByDate) {
|
||||
const year = media.createdAt.getFullYear();
|
||||
const month = media.createdAt.getMonth() + 1;
|
||||
const key = `${year}-${String(month).padStart(2, '0')}`;
|
||||
const existing = buckets.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.media.push(media);
|
||||
continue;
|
||||
}
|
||||
|
||||
buckets.set(key, { year, month, media: [media] });
|
||||
}
|
||||
|
||||
let orderedBuckets = Array.from(buckets.values())
|
||||
.sort((a, b) => (b.year * 12 + b.month) - (a.year * 12 + a.month));
|
||||
|
||||
if (yearParam === null) {
|
||||
orderedBuckets = orderedBuckets.slice(0, 10);
|
||||
}
|
||||
|
||||
for (const bucket of orderedBuckets) {
|
||||
bucket.media.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
return orderedBuckets;
|
||||
}
|
||||
|
||||
export function renderGalleryMacro(
|
||||
params: Record<string, string>,
|
||||
postId: string,
|
||||
mediaItems: MediaData[],
|
||||
linkedMediaIds: Set<string> | null,
|
||||
): string {
|
||||
const requestedColumns = parseIntegerParam(params.columns);
|
||||
const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3;
|
||||
const caption = params.caption ? `<figcaption class="gallery-caption">${escapeHtml(params.caption)}</figcaption>` : '';
|
||||
|
||||
const linkedImages = mediaItems
|
||||
.filter((media) => {
|
||||
if (!isRenderableImage(media)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const linkedByPostMedia = linkedMediaIds?.has(media.id) ?? false;
|
||||
const linkedBySidecar = Array.isArray(media.linkedPostIds) && media.linkedPostIds.includes(postId);
|
||||
return linkedByPostMedia || linkedBySidecar;
|
||||
})
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
const groupName = `gallery-${escapeHtml(postId || 'post')}`;
|
||||
const galleryItems = linkedImages.map((media) => {
|
||||
const mediaPath = escapeHtml(buildCanonicalMediaPath(media));
|
||||
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename);
|
||||
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename);
|
||||
return `<a class="gallery-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
|
||||
}).join('');
|
||||
|
||||
const content = galleryItems || '<div class="gallery-empty">No linked images found.</div>';
|
||||
return `<div class="macro-gallery gallery-cols-${columns}" data-post-id="${escapeHtml(postId)}" data-columns="${columns}" data-lightbox="true"><div class="gallery-container gallery-lightbox">${content}</div>${caption}</div>`;
|
||||
}
|
||||
|
||||
export function renderPhotoArchiveMacro(params: Record<string, string>, mediaItems: MediaData[]): string {
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const yearParam = parseIntegerParam(params.year);
|
||||
const monthParam = parseIntegerParam(params.month);
|
||||
|
||||
const rootClasses = ['macro-photo-archive'];
|
||||
if (yearParam === null) {
|
||||
rootClasses.push('photo-archive-recent-months');
|
||||
} else if (monthParam !== null) {
|
||||
rootClasses.push('photo-archive-single-month');
|
||||
} else {
|
||||
rootClasses.push('photo-archive-full-year');
|
||||
}
|
||||
|
||||
const dataAttrs: string[] = [];
|
||||
if (yearParam === null) {
|
||||
dataAttrs.push('data-recent="10"');
|
||||
} else {
|
||||
dataAttrs.push(`data-year="${escapeHtml(String(yearParam))}"`);
|
||||
if (monthParam !== null) {
|
||||
dataAttrs.push(`data-month="${escapeHtml(String(monthParam))}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const renderableMedia = mediaItems.filter((media) => isRenderableImage(media));
|
||||
const buckets = buildPhotoArchiveBuckets(renderableMedia, params);
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container"><div class="photo-archive-empty">No photos found for this archive.</div></div></div>`;
|
||||
}
|
||||
|
||||
const monthsHtml = buckets.map((bucket) => {
|
||||
const monthName = monthNames[bucket.month - 1] || String(bucket.month);
|
||||
const label = `${monthName} ${bucket.year}`;
|
||||
const groupName = `photo-archive-${bucket.year}-${String(bucket.month).padStart(2, '0')}`;
|
||||
|
||||
const itemsHtml = bucket.media.map((media) => {
|
||||
const mediaPath = escapeHtml(buildCanonicalMediaPath(media));
|
||||
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename);
|
||||
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename);
|
||||
return `<a class="photo-archive-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="photo-archive-month-wrapper"><div class="photo-archive-month"><div class="photo-archive-month-label"><span>${escapeHtml(label)}</span></div><div class="photo-archive-gallery">${itemsHtml}</div></div></div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container">${monthsHtml}</div></div>`;
|
||||
}
|
||||
|
||||
export function isExternalOrSpecialUrl(value: string): boolean {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized.startsWith('#') || normalized.startsWith('//')) return true;
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(normalized);
|
||||
}
|
||||
|
||||
export function splitPathSuffix(value: string): { pathPart: string; suffix: string } {
|
||||
const match = value.match(/^([^?#]*)([?#].*)?$/);
|
||||
return {
|
||||
pathPart: match?.[1] ?? value,
|
||||
suffix: match?.[2] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePreviewHref(rawHref: string, rewriteContext: HtmlRewriteContext): string {
|
||||
if (!rawHref || isExternalOrSpecialUrl(rawHref)) {
|
||||
return rawHref;
|
||||
}
|
||||
|
||||
const { pathPart, suffix } = splitPathSuffix(rawHref.trim());
|
||||
|
||||
const canonicalDayRouteMatch = pathPart.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([a-z0-9-]+)(?:\.html?)?$/i);
|
||||
if (canonicalDayRouteMatch) {
|
||||
const [, year, month, day, slug] = canonicalDayRouteMatch;
|
||||
const normalizedMonth = String(Number(month)).padStart(2, '0');
|
||||
const normalizedDay = String(Number(day)).padStart(2, '0');
|
||||
return `/${year}/${normalizedMonth}/${normalizedDay}/${slug}${suffix}`;
|
||||
}
|
||||
|
||||
const postBySlugMatch = pathPart.match(/^\/?post\/([a-z0-9-]+(?:\.html?)?)$/i);
|
||||
if (postBySlugMatch) {
|
||||
const slug = postBySlugMatch[1].replace(/\.html?$/i, '');
|
||||
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
|
||||
return `${canonical ?? `/posts/${slug}`}${suffix}`;
|
||||
}
|
||||
|
||||
const postByYearMonthSlugMatch = pathPart.match(/^\/?post\/(\d{4})\/(\d{1,2})\/([a-z0-9-]+(?:\.html?)?)$/i);
|
||||
if (postByYearMonthSlugMatch) {
|
||||
const [, , , rawSlug] = postByYearMonthSlugMatch;
|
||||
const slug = rawSlug.replace(/\.html?$/i, '');
|
||||
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
|
||||
return `${canonical ?? `/posts/${slug}`}${suffix}`;
|
||||
}
|
||||
|
||||
const postsBySlugMatch = pathPart.match(/^\/?posts\/([a-z0-9-]+(?:\.html?)?)$/i);
|
||||
if (postsBySlugMatch) {
|
||||
const slug = postsBySlugMatch[1].replace(/\.html?$/i, '');
|
||||
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
|
||||
return `${canonical ?? `/posts/${slug}`}${suffix}`;
|
||||
}
|
||||
|
||||
const postsByYearMonthSlugMatch = pathPart.match(/^\/?posts\/(\d{4})\/(\d{1,2})\/([a-z0-9-]+(?:\.html?)?)$/i);
|
||||
if (postsByYearMonthSlugMatch) {
|
||||
const [, , , rawSlug] = postsByYearMonthSlugMatch;
|
||||
const slug = rawSlug.replace(/\.html?$/i, '');
|
||||
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
|
||||
return `${canonical ?? `/posts/${slug}`}${suffix}`;
|
||||
}
|
||||
|
||||
return rawHref;
|
||||
}
|
||||
|
||||
export function normalizePreviewSrc(rawSrc: string, rewriteContext: HtmlRewriteContext): string {
|
||||
if (!rawSrc || isExternalOrSpecialUrl(rawSrc)) {
|
||||
return rawSrc;
|
||||
}
|
||||
|
||||
const { pathPart, suffix } = splitPathSuffix(rawSrc.trim());
|
||||
const mediaMatch = pathPart.match(/^\/?media\/(\d{4})\/(\d{2})\/([^\s]+)$/i);
|
||||
if (!mediaMatch) {
|
||||
return rawSrc;
|
||||
}
|
||||
|
||||
const [, year, month, filename] = mediaMatch;
|
||||
const sourceKey = `media/${year}/${month}/${filename}`.toLowerCase();
|
||||
const canonicalPath = rewriteContext.canonicalMediaPathBySourcePath.get(sourceKey);
|
||||
if (canonicalPath) {
|
||||
return `${canonicalPath}${suffix}`;
|
||||
}
|
||||
|
||||
return `/media/${year}/${month}/${filename}${suffix}`;
|
||||
}
|
||||
|
||||
export function rewriteRenderedHtmlUrls(html: string, rewriteContext: HtmlRewriteContext): string {
|
||||
return html
|
||||
.replace(/\bhref=(['"])(.*?)\1/gi, (_fullMatch, quote: string, href: string) => {
|
||||
const rewritten = normalizePreviewHref(href, rewriteContext);
|
||||
return `href=${quote}${rewritten}${quote}`;
|
||||
})
|
||||
.replace(/\bsrc=(['"])(.*?)\1/gi, (_fullMatch, quote: string, src: string) => {
|
||||
const rewritten = normalizePreviewSrc(src, rewriteContext);
|
||||
return `src=${quote}${rewritten}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderMacro(
|
||||
name: string,
|
||||
params: Record<string, string>,
|
||||
postId: string,
|
||||
mediaItems: MediaData[],
|
||||
linkedMediaIds: Set<string> | null,
|
||||
): string {
|
||||
const normalizedName = normalizeMacroName(name);
|
||||
|
||||
if (normalizedName === 'youtube') {
|
||||
const id = escapeHtml(params.id || '');
|
||||
const title = escapeHtml(params.title || 'YouTube video');
|
||||
if (!id) return '';
|
||||
return `<div class="macro-youtube"><iframe src="https://www.youtube.com/embed/${id}?rel=0" title="${title}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>`;
|
||||
}
|
||||
|
||||
if (normalizedName === 'vimeo') {
|
||||
const id = escapeHtml(params.id || '');
|
||||
const title = escapeHtml(params.title || 'Vimeo video');
|
||||
if (!id) return '';
|
||||
return `<div class="macro-vimeo"><iframe src="https://player.vimeo.com/video/${id}" title="${title}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>`;
|
||||
}
|
||||
|
||||
if (normalizedName === 'gallery') {
|
||||
return renderGalleryMacro(params, postId, mediaItems, linkedMediaIds);
|
||||
}
|
||||
|
||||
if (normalizedName === 'photo_archive') {
|
||||
return renderPhotoArchiveMacro(params, mediaItems);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildCanonicalPostPath(post: PostData): string {
|
||||
const year = post.createdAt.getFullYear();
|
||||
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(post.createdAt.getDate()).padStart(2, '0');
|
||||
return `/${year}/${month}/${day}/${post.slug}`;
|
||||
}
|
||||
|
||||
export function formatArchiveDate(date: Date): string {
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear());
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function getArchiveDateKey(date: Date): string {
|
||||
const year = String(date.getFullYear());
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function toDateParts(date: Date): { day: number; month: number; year: number } {
|
||||
return {
|
||||
day: date.getDate(),
|
||||
month: date.getMonth() + 1,
|
||||
year: date.getFullYear(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPaginationHref(basePathname: string, page: number): string {
|
||||
const base = basePathname === '/' ? '' : basePathname;
|
||||
if (page <= 1) {
|
||||
return basePathname === '/' ? '/' : `${basePathname}/`;
|
||||
}
|
||||
|
||||
return `${base}/page/${page}/`;
|
||||
}
|
||||
|
||||
export function parseRoutePagination(pathname: string): RoutePagination | null {
|
||||
const pageMatch = pathname.match(/^(.*)\/page\/(\d+)$/);
|
||||
if (!pageMatch) {
|
||||
return { pathname, page: 1 };
|
||||
}
|
||||
|
||||
const page = Number(pageMatch[2]);
|
||||
if (!Number.isInteger(page) || page < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePathname = pageMatch[1] || '/';
|
||||
return {
|
||||
pathname: basePathname,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapToRecord(map: Map<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(map.entries());
|
||||
}
|
||||
|
||||
export function recordToMap(record: unknown): Map<string, string> {
|
||||
if (!record || typeof record !== 'object') {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
return new Map<string, string>(
|
||||
Object.entries(record as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
|
||||
);
|
||||
}
|
||||
|
||||
export class PageRenderer {
|
||||
private readonly mediaEngine: MediaEngineContract;
|
||||
private readonly postMediaEngine: PostMediaEngineContract;
|
||||
private readonly liquid: Liquid;
|
||||
|
||||
constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract) {
|
||||
this.mediaEngine = mediaEngine;
|
||||
this.postMediaEngine = postMediaEngine;
|
||||
|
||||
const templateRoots = [
|
||||
path.resolve(__dirname, 'templates'),
|
||||
path.resolve(process.cwd(), 'dist', 'main', 'engine', 'templates'),
|
||||
path.resolve(process.cwd(), 'src', 'main', 'engine', 'templates'),
|
||||
];
|
||||
|
||||
this.liquid = new Liquid({
|
||||
root: templateRoots,
|
||||
extname: '.liquid',
|
||||
cache: true,
|
||||
});
|
||||
|
||||
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown) => {
|
||||
const content = typeof value === 'string' ? value : '';
|
||||
const postId = typeof postIdArg === 'string' ? postIdArg : '';
|
||||
const rewriteContext: HtmlRewriteContext = {
|
||||
canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
|
||||
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
|
||||
};
|
||||
|
||||
const needsMediaLookup = /\[\[(gallery|photo_archive|photo_album)\b/i.test(content);
|
||||
const mediaItems = needsMediaLookup
|
||||
? await this.mediaEngine.getAllMedia().catch(() => [] as MediaData[])
|
||||
: [];
|
||||
|
||||
const linkedMediaIds = needsMediaLookup && postId
|
||||
? await this.postMediaEngine.getLinkedMediaDataForPost(postId)
|
||||
.then((links) => new Set<string>(links.map((link) => link.media?.id).filter((id): id is string => typeof id === 'string' && id.length > 0)))
|
||||
.catch(() => null)
|
||||
: null;
|
||||
|
||||
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
|
||||
const params = parseMacroParams(rawParams);
|
||||
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds);
|
||||
});
|
||||
|
||||
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
|
||||
return rewriteRenderedHtmlUrls(markdownHtml, rewriteContext);
|
||||
});
|
||||
}
|
||||
|
||||
buildListTemplateContext(
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
options: {
|
||||
archiveGrouping: boolean;
|
||||
routeKind: ArchiveRouteKind;
|
||||
archiveContext?: DateArchiveContext;
|
||||
basePathname: string;
|
||||
page_title: string;
|
||||
language: string;
|
||||
pagination?: PaginationContext;
|
||||
},
|
||||
): PostListTemplateContext {
|
||||
const dayBlocks: DayBlockContext[] = [];
|
||||
|
||||
if (!options.archiveGrouping) {
|
||||
dayBlocks.push({
|
||||
date_label: '',
|
||||
show_date_marker: false,
|
||||
show_separator: false,
|
||||
posts: posts.map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
content: post.content,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
let currentBlock: { key: string; block: DayBlockContext } | null = null;
|
||||
|
||||
for (const post of posts) {
|
||||
const key = getArchiveDateKey(post.createdAt);
|
||||
if (!currentBlock || currentBlock.key !== key) {
|
||||
currentBlock = {
|
||||
key,
|
||||
block: {
|
||||
date_label: formatArchiveDate(post.createdAt),
|
||||
show_date_marker: true,
|
||||
show_separator: false,
|
||||
posts: [],
|
||||
},
|
||||
};
|
||||
dayBlocks.push(currentBlock.block);
|
||||
}
|
||||
|
||||
currentBlock.block.posts.push({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
content: post.content,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < dayBlocks.length - 1; index += 1) {
|
||||
dayBlocks[index].show_separator = true;
|
||||
}
|
||||
}
|
||||
|
||||
const pagination = options.pagination;
|
||||
const isListPage = Boolean(pagination && pagination.totalPosts > pagination.maxPostsPerPage);
|
||||
const isFirstPage = pagination ? pagination.page <= 1 : true;
|
||||
const isLastPage = pagination
|
||||
? (pagination.page * pagination.maxPostsPerPage) >= pagination.totalPosts
|
||||
: true;
|
||||
const hasPrevPage = Boolean(pagination && pagination.page > 1);
|
||||
const hasNextPage = Boolean(pagination && (pagination.page * pagination.maxPostsPerPage) < pagination.totalPosts);
|
||||
const prevPageHref = hasPrevPage
|
||||
? buildPaginationHref(options.basePathname, (pagination as PaginationContext).page - 1)
|
||||
: '';
|
||||
const nextPageHref = hasNextPage
|
||||
? buildPaginationHref(options.basePathname, (pagination as PaginationContext).page + 1)
|
||||
: '';
|
||||
|
||||
let minDateParts: { day: number; month: number; year: number } | null = null;
|
||||
let maxDateParts: { day: number; month: number; year: number } | null = null;
|
||||
|
||||
const hasRangeHeading = Boolean(
|
||||
!isFirstPage
|
||||
&& posts.length > 0
|
||||
&& (
|
||||
options.routeKind === 'date'
|
||||
|| options.archiveContext?.kind === 'tag'
|
||||
|| options.archiveContext?.kind === 'category'
|
||||
),
|
||||
);
|
||||
|
||||
if (hasRangeHeading) {
|
||||
let minDate = posts[0].createdAt;
|
||||
let maxDate = posts[0].createdAt;
|
||||
|
||||
for (const post of posts) {
|
||||
if (post.createdAt.getTime() < minDate.getTime()) {
|
||||
minDate = post.createdAt;
|
||||
}
|
||||
if (post.createdAt.getTime() > maxDate.getTime()) {
|
||||
maxDate = post.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
minDateParts = toDateParts(minDate);
|
||||
maxDateParts = toDateParts(maxDate);
|
||||
}
|
||||
|
||||
return {
|
||||
page_title: options.page_title,
|
||||
language: options.language,
|
||||
is_date_archive: options.routeKind === 'date',
|
||||
show_archive_range_heading: hasRangeHeading,
|
||||
archive_context: options.routeKind === 'date'
|
||||
? {
|
||||
kind: options.archiveContext?.kind ?? 'root',
|
||||
name: options.archiveContext?.name ?? null,
|
||||
year: options.archiveContext?.year ?? null,
|
||||
month: options.archiveContext?.month ?? null,
|
||||
day: options.archiveContext?.day ?? null,
|
||||
}
|
||||
: options.archiveContext
|
||||
? {
|
||||
kind: options.archiveContext.kind,
|
||||
name: options.archiveContext.name ?? null,
|
||||
year: options.archiveContext.year ?? null,
|
||||
month: options.archiveContext.month ?? null,
|
||||
day: options.archiveContext.day ?? null,
|
||||
}
|
||||
: null,
|
||||
min_date: minDateParts,
|
||||
max_date: maxDateParts,
|
||||
is_list_page: isListPage,
|
||||
is_first_page: isFirstPage,
|
||||
is_last_page: isLastPage,
|
||||
has_prev_page: hasPrevPage,
|
||||
has_next_page: hasNextPage,
|
||||
prev_page_href: prevPageHref,
|
||||
next_page_href: nextPageHref,
|
||||
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
|
||||
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
|
||||
day_blocks: dayBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract): Promise<PostData> {
|
||||
if (post.status === 'published' && !post.content) {
|
||||
const fullPost = await postEngine.getPost(post.id);
|
||||
return fullPost ?? post;
|
||||
}
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
async renderPostList(
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
options: {
|
||||
archiveGrouping: boolean;
|
||||
routeKind: ArchiveRouteKind;
|
||||
archiveContext?: DateArchiveContext;
|
||||
basePathname: string;
|
||||
page_title: string;
|
||||
language: string;
|
||||
pagination?: PaginationContext;
|
||||
},
|
||||
postEngine?: PostEngineContract,
|
||||
): Promise<string> {
|
||||
if (posts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const renderablePosts = postEngine
|
||||
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine)))
|
||||
: posts;
|
||||
const templateContext = this.buildListTemplateContext(
|
||||
renderablePosts,
|
||||
rewriteContext,
|
||||
options,
|
||||
);
|
||||
|
||||
return this.liquid.renderFile('post-list', templateContext);
|
||||
}
|
||||
|
||||
async renderSinglePost(
|
||||
post: PostData,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
pageContext: { page_title: string; language: string },
|
||||
postEngine?: PostEngineContract,
|
||||
): Promise<string> {
|
||||
const renderablePost = postEngine
|
||||
? await this.resolveRenderablePost(post, postEngine)
|
||||
: post;
|
||||
const context: SinglePostTemplateContext = {
|
||||
...pageContext,
|
||||
post: {
|
||||
id: renderablePost.id,
|
||||
title: renderablePost.title,
|
||||
content: renderablePost.content,
|
||||
},
|
||||
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
|
||||
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
|
||||
};
|
||||
|
||||
return this.liquid.renderFile('single-post', context);
|
||||
}
|
||||
|
||||
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
||||
return this.liquid.renderFile('not-found', context);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,15 @@ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cance
|
||||
|
||||
export interface TaskProgress {
|
||||
taskId: string;
|
||||
name: string;
|
||||
status: TaskStatus;
|
||||
progress: number; // 0-100
|
||||
message: string;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
error?: string;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
export interface Task<T = unknown> {
|
||||
@@ -17,6 +20,8 @@ export interface Task<T = unknown> {
|
||||
name: string;
|
||||
execute: (onProgress: (progress: number, message: string) => void) => Promise<T>;
|
||||
cancel?: () => void;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
export class TaskManager extends EventEmitter {
|
||||
@@ -44,10 +49,13 @@ export class TaskManager extends EventEmitter {
|
||||
async runTask<T>(task: Task<T>): Promise<T> {
|
||||
const progress: TaskProgress = {
|
||||
taskId: task.id,
|
||||
name: task.name,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: 'Waiting to start...',
|
||||
startTime: new Date(),
|
||||
groupId: task.groupId,
|
||||
groupName: task.groupName,
|
||||
};
|
||||
|
||||
this.tasks.set(task.id, progress);
|
||||
|
||||
Reference in New Issue
Block a user