feat: added feed generation
This commit is contained in:
430
src/main/engine/BlogGenerationEngine.ts
Normal file
430
src/main/engine/BlogGenerationEngine.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { getDatabase } from '../database';
|
||||||
|
import { getPostEngine, type PostData } from './PostEngine';
|
||||||
|
|
||||||
|
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||||
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||||
|
const MAX_MAX_POSTS_PER_PAGE = 500;
|
||||||
|
|
||||||
|
export interface BlogGenerationOptions {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
projectDescription?: string;
|
||||||
|
dataDir: string;
|
||||||
|
baseUrl: string;
|
||||||
|
maxPostsPerPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogGenerationResult {
|
||||||
|
path: string;
|
||||||
|
urlCount: number;
|
||||||
|
postCount: number;
|
||||||
|
feedPostCount: number;
|
||||||
|
tagCount: number;
|
||||||
|
categoryCount: number;
|
||||||
|
archiveCount: number;
|
||||||
|
feeds: {
|
||||||
|
rssPath: string;
|
||||||
|
atomPath: string;
|
||||||
|
};
|
||||||
|
changed: {
|
||||||
|
sitemap: boolean;
|
||||||
|
rss: boolean;
|
||||||
|
atom: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePublicBaseUrl(publicUrl?: string): string | null {
|
||||||
|
const trimmed = (publicUrl || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
||||||
|
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
return `/${year}/${month}/${day}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||||
|
if (post.createdAt instanceof Date) {
|
||||||
|
return post.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(post.createdAt);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: unknown): string {
|
||||||
|
const str = typeof value === 'string' ? value : value == null ? '' : String(value);
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSitemapUrl(
|
||||||
|
loc: string,
|
||||||
|
lastmod: string,
|
||||||
|
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
||||||
|
priority: string,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
' <url>',
|
||||||
|
` <loc>${escapeXml(loc)}</loc>`,
|
||||||
|
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
|
||||||
|
` <changefreq>${changefreq}</changefreq>`,
|
||||||
|
` <priority>${priority}</priority>`,
|
||||||
|
' </url>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitParagraphs(markdown: string | null | undefined): string[] {
|
||||||
|
const normalizedMarkdown = typeof markdown === 'string' ? markdown : '';
|
||||||
|
return normalizedMarkdown
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((paragraph) => paragraph.trim())
|
||||||
|
.filter((paragraph) => paragraph.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphToXhtml(paragraph: string): string {
|
||||||
|
const escaped = escapeXml(paragraph).replace(/\n/g, '<br />');
|
||||||
|
return `<p>${escaped}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownToXhtml(markdown: string): string {
|
||||||
|
const paragraphs = splitParagraphs(markdown);
|
||||||
|
if (paragraphs.length === 0) {
|
||||||
|
return '<p></p>';
|
||||||
|
}
|
||||||
|
return paragraphs.map(paragraphToXhtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function excerptToXhtml(post: PostData): string {
|
||||||
|
if (typeof post.excerpt === 'string' && post.excerpt.trim().length > 0) {
|
||||||
|
return paragraphToXhtml(post.excerpt.trim());
|
||||||
|
}
|
||||||
|
const firstParagraph = splitParagraphs(post.content)[0] || '';
|
||||||
|
return paragraphToXhtml(firstParagraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCdata(value: string): string {
|
||||||
|
return value.replace(/]]>/g, ']]]]><![CDATA[>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const hash = computeContentHash(content);
|
||||||
|
const previousHash = await getHashSettingValue(hashKey);
|
||||||
|
if (previousHash === hash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await fs.writeFile(filePath, content, 'utf-8');
|
||||||
|
await setHashSettingValue(hashKey, hash);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlogGenerationEngine {
|
||||||
|
private readonly postEngine = getPostEngine();
|
||||||
|
|
||||||
|
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
|
||||||
|
onProgress(0, 'Loading posts...');
|
||||||
|
|
||||||
|
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||||
|
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
|
||||||
|
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
|
||||||
|
|
||||||
|
const publishedSnapshots = await Promise.all(
|
||||||
|
publishedCandidates.map(async (post) => {
|
||||||
|
const snapshot = await this.postEngine.getPublishedVersion(post.id);
|
||||||
|
return snapshot || post;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const draftPublishedSnapshots = await Promise.all(
|
||||||
|
draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const publishedPostById = new Map<string, PostData>();
|
||||||
|
for (const post of publishedSnapshots) {
|
||||||
|
publishedPostById.set(post.id, post);
|
||||||
|
}
|
||||||
|
for (const snapshot of draftPublishedSnapshots) {
|
||||||
|
if (snapshot) {
|
||||||
|
publishedPostById.set(snapshot.id, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedPosts = Array.from(publishedPostById.values())
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
const feedPosts = publishedPosts.slice(0, maxPostsPerPage);
|
||||||
|
|
||||||
|
onProgress(10, `Found ${publishedPosts.length} published posts`);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
const allCategories = new Set<string>();
|
||||||
|
const yearMonths = new Map<string, Date>();
|
||||||
|
const years = new Map<number, Date>();
|
||||||
|
const yearMonthDays = new Map<string, Date>();
|
||||||
|
const postUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||||
|
|
||||||
|
for (const post of publishedPosts) {
|
||||||
|
for (const tag of post.tags || []) allTags.add(tag);
|
||||||
|
for (const category of post.categories || []) allCategories.add(category);
|
||||||
|
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||||
|
const postUrl = `${options.baseUrl}${canonicalPath}`;
|
||||||
|
const updatedAt = post.updatedAt;
|
||||||
|
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
|
||||||
|
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
const ymKey = `${year}/${month}`;
|
||||||
|
const ymdKey = `${year}/${month}/${day}`;
|
||||||
|
|
||||||
|
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
||||||
|
yearMonths.set(ymKey, updatedAt);
|
||||||
|
}
|
||||||
|
if (!years.has(year) || updatedAt > years.get(year)!) {
|
||||||
|
years.set(year, updatedAt);
|
||||||
|
}
|
||||||
|
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
||||||
|
yearMonthDays.set(ymdKey, updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now;
|
||||||
|
|
||||||
|
onProgress(40, 'Building sitemap XML...');
|
||||||
|
|
||||||
|
const urls: string[] = [];
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
|
||||||
|
for (const post of postUrls) {
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||||
|
}
|
||||||
|
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
|
||||||
|
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...');
|
||||||
|
|
||||||
|
const sitemapXml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...urls,
|
||||||
|
'</urlset>',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const feedUpdatedAt = feedPosts[0]?.updatedAt || new Date();
|
||||||
|
const baseLink = `${options.baseUrl}/`;
|
||||||
|
const feedTitle = options.projectName;
|
||||||
|
const feedDescription = options.projectDescription?.trim() || feedTitle;
|
||||||
|
|
||||||
|
const rssItems = feedPosts.map((post) => {
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||||
|
const permalink = `${options.baseUrl}${canonicalPath}`;
|
||||||
|
const excerptXhtml = excerptToXhtml(post);
|
||||||
|
const contentXhtml = markdownToXhtml(post.content || '');
|
||||||
|
const categories = [
|
||||||
|
...(post.categories || []).map((category) => `<category>${escapeXml(category)}</category>`),
|
||||||
|
...(post.tags || []).map((tag) => `<category>${escapeXml(tag)}</category>`),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
' <item>',
|
||||||
|
` <title>${escapeXml(post.title)}</title>`,
|
||||||
|
` <link>${escapeXml(permalink)}</link>`,
|
||||||
|
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
|
||||||
|
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
|
||||||
|
post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
|
||||||
|
` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`,
|
||||||
|
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
|
||||||
|
...categories.map((entry) => ` ${entry}`),
|
||||||
|
' </item>',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const rssXml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
|
||||||
|
' <channel>',
|
||||||
|
` <title>${escapeXml(feedTitle)}</title>`,
|
||||||
|
` <link>${escapeXml(baseLink)}</link>`,
|
||||||
|
` <description>${escapeXml(feedDescription)}</description>`,
|
||||||
|
` <lastBuildDate>${feedUpdatedAt.toUTCString()}</lastBuildDate>`,
|
||||||
|
' <generator>bDS</generator>',
|
||||||
|
...rssItems,
|
||||||
|
' </channel>',
|
||||||
|
'</rss>',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const atomEntries = feedPosts.map((post) => {
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||||
|
const permalink = `${options.baseUrl}${canonicalPath}`;
|
||||||
|
const excerptXhtml = excerptToXhtml(post);
|
||||||
|
const contentXhtml = markdownToXhtml(post.content || '');
|
||||||
|
const categories = [
|
||||||
|
...(post.tags || []).map((tag) => `<category term="${escapeXml(tag)}" />`),
|
||||||
|
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
' <entry>',
|
||||||
|
` <title>${escapeXml(post.title)}</title>`,
|
||||||
|
` <id>${escapeXml(permalink)}</id>`,
|
||||||
|
` <link href="${escapeXml(permalink)}" />`,
|
||||||
|
` <updated>${post.updatedAt.toISOString()}</updated>`,
|
||||||
|
` <published>${(post.publishedAt || post.updatedAt).toISOString()}</published>`,
|
||||||
|
post.author ? ` <author><name>${escapeXml(post.author)}</name></author>` : null,
|
||||||
|
` <summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${excerptXhtml}</div></summary>`,
|
||||||
|
` <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml">${contentXhtml}</div></content>`,
|
||||||
|
...categories.map((entry) => ` ${entry}`),
|
||||||
|
' </entry>',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const atomXml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<feed xmlns="http://www.w3.org/2005/Atom">',
|
||||||
|
` <title>${escapeXml(feedTitle)}</title>`,
|
||||||
|
` <subtitle>${escapeXml(feedDescription)}</subtitle>`,
|
||||||
|
` <id>${escapeXml(baseLink)}</id>`,
|
||||||
|
` <link href="${escapeXml(baseLink)}" rel="alternate" />`,
|
||||||
|
` <link href="${escapeXml(`${baseLink}atom.xml`)}" rel="self" />`,
|
||||||
|
` <updated>${feedUpdatedAt.toISOString()}</updated>`,
|
||||||
|
...atomEntries,
|
||||||
|
'</feed>',
|
||||||
|
'',
|
||||||
|
].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`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
onProgress(100, `Sitemap and feeds generated (${feedPosts.length} feed posts)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: sitemapPath,
|
||||||
|
urlCount: urls.length,
|
||||||
|
postCount: postUrls.length,
|
||||||
|
feedPostCount: feedPosts.length,
|
||||||
|
tagCount: allTags.size,
|
||||||
|
categoryCount: allCategories.size,
|
||||||
|
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
|
||||||
|
feeds: {
|
||||||
|
rssPath,
|
||||||
|
atomPath,
|
||||||
|
},
|
||||||
|
changed: {
|
||||||
|
sitemap: sitemapWritten,
|
||||||
|
rss: rssWritten,
|
||||||
|
atom: atomWritten,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let blogGenerationEngine: BlogGenerationEngine | null = null;
|
||||||
|
|
||||||
|
export function getBlogGenerationEngine(): BlogGenerationEngine {
|
||||||
|
if (!blogGenerationEngine) {
|
||||||
|
blogGenerationEngine = new BlogGenerationEngine();
|
||||||
|
}
|
||||||
|
return blogGenerationEngine;
|
||||||
|
}
|
||||||
@@ -86,3 +86,10 @@ export {
|
|||||||
type GitStatusCounts,
|
type GitStatusCounts,
|
||||||
type GitInitResult,
|
type GitInitResult,
|
||||||
} from './GitEngine';
|
} from './GitEngine';
|
||||||
|
export {
|
||||||
|
BlogGenerationEngine,
|
||||||
|
getBlogGenerationEngine,
|
||||||
|
resolvePublicBaseUrl,
|
||||||
|
type BlogGenerationOptions,
|
||||||
|
type BlogGenerationResult,
|
||||||
|
} from './BlogGenerationEngine';
|
||||||
|
|||||||
59
src/main/ipc/blogHandlers.ts
Normal file
59
src/main/ipc/blogHandlers.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { dialog } from 'electron';
|
||||||
|
import { getPostEngine } from '../engine/PostEngine';
|
||||||
|
import { getProjectEngine } from '../engine/ProjectEngine';
|
||||||
|
import { getMetaEngine } from '../engine/MetaEngine';
|
||||||
|
import { taskManager } from '../engine/TaskManager';
|
||||||
|
import { getBlogGenerationEngine, resolvePublicBaseUrl } from '../engine/BlogGenerationEngine';
|
||||||
|
|
||||||
|
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||||
|
|
||||||
|
export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
||||||
|
safeHandle('blog:generateSitemap', async () => {
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const postEngine = getPostEngine();
|
||||||
|
const metaEngine = getMetaEngine();
|
||||||
|
const blogGenerationEngine = getBlogGenerationEngine();
|
||||||
|
|
||||||
|
const project = await projectEngine.getActiveProject();
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('No active project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
|
|
||||||
|
if (!metaEngine.isInitialized()) {
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await metaEngine.getProjectMetadata();
|
||||||
|
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
|
||||||
|
if (!baseUrl) {
|
||||||
|
await dialog.showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Public URL Required',
|
||||||
|
message: 'Sitemap generation requires a public URL.',
|
||||||
|
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
|
||||||
|
});
|
||||||
|
throw new Error('Project public URL is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = `sitemap-generate-${Date.now()}`;
|
||||||
|
|
||||||
|
return taskManager.runTask({
|
||||||
|
id: taskId,
|
||||||
|
name: 'Generate Sitemap',
|
||||||
|
execute: async (onProgress) => {
|
||||||
|
return blogGenerationEngine.generate({
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: metadata?.name?.trim() || project.name,
|
||||||
|
projectDescription: metadata?.description,
|
||||||
|
dataDir,
|
||||||
|
baseUrl,
|
||||||
|
maxPostsPerPage: metadata?.maxPostsPerPage,
|
||||||
|
}, (progress, message) => onProgress(progress, message || ''));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { taskManager, TaskProgress } from '../engine/TaskManager';
|
|||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { media } from '../database/schema';
|
import { media } from '../database/schema';
|
||||||
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
|
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
|
||||||
|
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
|
||||||
|
import { registerBlogHandlers } from './blogHandlers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap an IPC handler so that "Database is closing" errors during shutdown
|
* Wrap an IPC handler so that "Database is closing" errors during shutdown
|
||||||
@@ -86,50 +88,6 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeXml(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSitemapUrl(
|
|
||||||
loc: string,
|
|
||||||
lastmod: string,
|
|
||||||
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
|
||||||
priority: string,
|
|
||||||
): string {
|
|
||||||
return [
|
|
||||||
' <url>',
|
|
||||||
` <loc>${escapeXml(loc)}</loc>`,
|
|
||||||
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
|
|
||||||
` <changefreq>${changefreq}</changefreq>`,
|
|
||||||
` <priority>${priority}</priority>`,
|
|
||||||
' </url>',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePublicBaseUrl(publicUrl?: string): string | null {
|
|
||||||
const trimmed = (publicUrl || '').trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(trimmed);
|
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
|
||||||
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ============ Git Handlers ============
|
// ============ Git Handlers ============
|
||||||
|
|
||||||
@@ -1263,252 +1221,8 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.deleteDefinition(id);
|
return engine.deleteDefinition(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ Metadata Diff Handlers ============
|
registerMetadataDiffHandlers(safeHandle);
|
||||||
|
registerBlogHandlers(safeHandle);
|
||||||
safeHandle('metadataDiff:getStats', async () => {
|
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
|
||||||
const engine = getMetadataDiffEngine();
|
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
|
||||||
if (activeProject) {
|
|
||||||
engine.setProjectContext(activeProject.id);
|
|
||||||
}
|
|
||||||
return engine.getTableStats();
|
|
||||||
});
|
|
||||||
|
|
||||||
safeHandle('metadataDiff:scan', async () => {
|
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
|
||||||
const engine = getMetadataDiffEngine();
|
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
|
||||||
if (activeProject) {
|
|
||||||
engine.setProjectContext(activeProject.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward progress events to renderer
|
|
||||||
const taskId = `metadata-diff-scan-${Date.now()}`;
|
|
||||||
|
|
||||||
return taskManager.runTask({
|
|
||||||
id: taskId,
|
|
||||||
name: 'Scanning for metadata differences',
|
|
||||||
execute: async (onProgress) => {
|
|
||||||
return engine.scanAllPublishedPosts((current, total, message) => {
|
|
||||||
const percent = total > 0 ? (current / total) * 100 : 0;
|
|
||||||
onProgress(percent, message);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
|
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
|
||||||
const engine = getMetadataDiffEngine();
|
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
|
||||||
if (activeProject) {
|
|
||||||
engine.setProjectContext(activeProject.id);
|
|
||||||
}
|
|
||||||
return engine.runSyncDbToFileTask(postIds, groupLabel);
|
|
||||||
});
|
|
||||||
|
|
||||||
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
|
|
||||||
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
|
||||||
const engine = getMetadataDiffEngine();
|
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const activeProject = await projectEngine.getActiveProject();
|
|
||||||
if (activeProject) {
|
|
||||||
engine.setProjectContext(activeProject.id);
|
|
||||||
}
|
|
||||||
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ Sitemap Generation ============
|
|
||||||
|
|
||||||
safeHandle('blog:generateSitemap', async () => {
|
|
||||||
const projectEngine = getProjectEngine();
|
|
||||||
const postEngine = getPostEngine();
|
|
||||||
const metaEngine = getMetaEngine();
|
|
||||||
const project = await projectEngine.getActiveProject();
|
|
||||||
if (!project) {
|
|
||||||
throw new Error('No active project');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
|
||||||
metaEngine.setProjectContext(project.id, dataDir);
|
|
||||||
|
|
||||||
if (!metaEngine.isInitialized()) {
|
|
||||||
await metaEngine.syncOnStartup();
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = await metaEngine.getProjectMetadata();
|
|
||||||
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
|
|
||||||
if (!baseUrl) {
|
|
||||||
await dialog.showMessageBox({
|
|
||||||
type: 'warning',
|
|
||||||
title: 'Public URL Required',
|
|
||||||
message: 'Sitemap generation requires a public URL.',
|
|
||||||
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
|
|
||||||
});
|
|
||||||
throw new Error('Project public URL is not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskId = `sitemap-generate-${Date.now()}`;
|
|
||||||
|
|
||||||
return taskManager.runTask({
|
|
||||||
id: taskId,
|
|
||||||
name: 'Generate Sitemap',
|
|
||||||
execute: async (onProgress) => {
|
|
||||||
onProgress(0, 'Loading posts...');
|
|
||||||
|
|
||||||
const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' });
|
|
||||||
const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
|
|
||||||
|
|
||||||
const draftPublishedSnapshots = await Promise.all(
|
|
||||||
draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const publishedPostById = new Map<string, PostData>();
|
|
||||||
for (const post of publishedCandidates) {
|
|
||||||
publishedPostById.set(post.id, post);
|
|
||||||
}
|
|
||||||
for (const snapshot of draftPublishedSnapshots) {
|
|
||||||
if (snapshot) {
|
|
||||||
publishedPostById.set(snapshot.id, snapshot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishedPosts = Array.from(publishedPostById.values())
|
|
||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
||||||
|
|
||||||
onProgress(10, `Found ${publishedPosts.length} published posts`);
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Collect all unique tags, categories, and year/month/day archives
|
|
||||||
const allTags = new Set<string>();
|
|
||||||
const allCategories = new Set<string>();
|
|
||||||
const yearMonths = new Map<string, Date>(); // key -> most recent post date
|
|
||||||
const years = new Map<number, Date>(); // year -> most recent post date
|
|
||||||
const yearMonthDays = new Map<string, Date>(); // YYYY/MM/DD -> most recent post date
|
|
||||||
|
|
||||||
const postUrls: Array<{ loc: string; lastmod: string }> = [];
|
|
||||||
|
|
||||||
for (const post of publishedPosts) {
|
|
||||||
const tags = post.tags || [];
|
|
||||||
const categories = post.categories || [];
|
|
||||||
|
|
||||||
for (const tag of tags) allTags.add(tag);
|
|
||||||
for (const cat of categories) allCategories.add(cat);
|
|
||||||
|
|
||||||
// Build canonical post URL using shared helpers
|
|
||||||
const createdAt = resolvePostCreatedAt(post);
|
|
||||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
|
||||||
const postUrl = `${baseUrl}${canonicalPath}`;
|
|
||||||
const updatedAt = post.updatedAt;
|
|
||||||
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
|
|
||||||
|
|
||||||
// Track archives
|
|
||||||
const year = createdAt.getFullYear();
|
|
||||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(createdAt.getDate()).padStart(2, '0');
|
|
||||||
const ymKey = `${year}/${month}`;
|
|
||||||
const ymdKey = `${year}/${month}/${day}`;
|
|
||||||
|
|
||||||
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
|
||||||
yearMonths.set(ymKey, updatedAt);
|
|
||||||
}
|
|
||||||
if (!years.has(year) || updatedAt > years.get(year)!) {
|
|
||||||
years.set(year, updatedAt);
|
|
||||||
}
|
|
||||||
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
|
||||||
yearMonthDays.set(ymdKey, updatedAt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(40, 'Building sitemap XML...');
|
|
||||||
|
|
||||||
// Build XML sitemap
|
|
||||||
const urls: string[] = [];
|
|
||||||
|
|
||||||
// Homepage
|
|
||||||
urls.push(buildSitemapUrl(baseUrl + '/', now, 'daily', '1.0'));
|
|
||||||
|
|
||||||
// Individual posts
|
|
||||||
for (const post of postUrls) {
|
|
||||||
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(55, 'Adding archive pages...');
|
|
||||||
|
|
||||||
// Year archives
|
|
||||||
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
|
|
||||||
urls.push(buildSitemapUrl(`${baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Year/Month archives
|
|
||||||
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
|
|
||||||
urls.push(buildSitemapUrl(`${baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Year/Month/Day archives
|
|
||||||
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
|
|
||||||
urls.push(buildSitemapUrl(`${baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(70, 'Adding category pages...');
|
|
||||||
|
|
||||||
// Category pages
|
|
||||||
for (const category of Array.from(allCategories).sort()) {
|
|
||||||
urls.push(buildSitemapUrl(
|
|
||||||
`${baseUrl}/category/${encodeURIComponent(category)}`,
|
|
||||||
now,
|
|
||||||
'weekly',
|
|
||||||
'0.6',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(80, 'Adding tag pages...');
|
|
||||||
|
|
||||||
// Tag pages
|
|
||||||
for (const tag of Array.from(allTags).sort()) {
|
|
||||||
urls.push(buildSitemapUrl(
|
|
||||||
`${baseUrl}/tag/${encodeURIComponent(tag)}`,
|
|
||||||
now,
|
|
||||||
'weekly',
|
|
||||||
'0.6',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(90, 'Writing sitemap file...');
|
|
||||||
|
|
||||||
const xml = [
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
||||||
...urls,
|
|
||||||
'</urlset>',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
// Write to html folder in the project data directory
|
|
||||||
const htmlDir = path.join(dataDir, 'html');
|
|
||||||
await fsPromises.mkdir(htmlDir, { recursive: true });
|
|
||||||
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
|
|
||||||
await fsPromises.writeFile(sitemapPath, xml, 'utf-8');
|
|
||||||
|
|
||||||
onProgress(100, `Sitemap generated with ${urls.length} URLs`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: sitemapPath,
|
|
||||||
urlCount: urls.length,
|
|
||||||
postCount: postUrls.length,
|
|
||||||
tagCount: allTags.size,
|
|
||||||
categoryCount: allCategories.size,
|
|
||||||
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ Event Forwarding ============
|
// ============ Event Forwarding ============
|
||||||
|
|
||||||
|
|||||||
61
src/main/ipc/metadataDiffHandlers.ts
Normal file
61
src/main/ipc/metadataDiffHandlers.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { getProjectEngine } from '../engine/ProjectEngine';
|
||||||
|
import { taskManager } from '../engine/TaskManager';
|
||||||
|
|
||||||
|
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||||
|
|
||||||
|
export function registerMetadataDiffHandlers(safeHandle: SafeHandle): void {
|
||||||
|
safeHandle('metadataDiff:getStats', async () => {
|
||||||
|
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
||||||
|
const engine = getMetadataDiffEngine();
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
|
if (activeProject) {
|
||||||
|
engine.setProjectContext(activeProject.id);
|
||||||
|
}
|
||||||
|
return engine.getTableStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('metadataDiff:scan', async () => {
|
||||||
|
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
||||||
|
const engine = getMetadataDiffEngine();
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
|
if (activeProject) {
|
||||||
|
engine.setProjectContext(activeProject.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = `metadata-diff-scan-${Date.now()}`;
|
||||||
|
return taskManager.runTask({
|
||||||
|
id: taskId,
|
||||||
|
name: 'Scanning for metadata differences',
|
||||||
|
execute: async (onProgress) => {
|
||||||
|
return engine.scanAllPublishedPosts((current, total, message) => {
|
||||||
|
const percent = total > 0 ? (current / total) * 100 : 0;
|
||||||
|
onProgress(percent, message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('metadataDiff:syncDbToFile', async (_, postIds: string[], groupLabel: string) => {
|
||||||
|
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
||||||
|
const engine = getMetadataDiffEngine();
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
|
if (activeProject) {
|
||||||
|
engine.setProjectContext(activeProject.id);
|
||||||
|
}
|
||||||
|
return engine.runSyncDbToFileTask(postIds, groupLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
|
||||||
|
const { getMetadataDiffEngine } = await import('../engine/MetadataDiffEngine');
|
||||||
|
const engine = getMetadataDiffEngine();
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
|
if (activeProject) {
|
||||||
|
engine.setProjectContext(activeProject.id);
|
||||||
|
}
|
||||||
|
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -175,6 +175,8 @@ const mockTaskManager = {
|
|||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockSettingsStore = new Map<string, string>();
|
||||||
|
|
||||||
const mockDatabase = {
|
const mockDatabase = {
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
select: vi.fn(() => ({
|
select: vi.fn(() => ({
|
||||||
@@ -185,6 +187,27 @@ const mockDatabase = {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
|
getLocalClient: vi.fn(() => ({
|
||||||
|
execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
|
||||||
|
if (sql.startsWith('SELECT value FROM settings WHERE key = ?')) {
|
||||||
|
const key = String(args?.[0] ?? '');
|
||||||
|
return {
|
||||||
|
rows: mockSettingsStore.has(key)
|
||||||
|
? [{ value: mockSettingsStore.get(key) as string }]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.startsWith('INSERT INTO settings')) {
|
||||||
|
const key = String(args?.[0] ?? '');
|
||||||
|
const value = String(args?.[1] ?? '');
|
||||||
|
mockSettingsStore.set(key, value);
|
||||||
|
return { rowsAffected: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows: [] };
|
||||||
|
}),
|
||||||
|
})),
|
||||||
getDataPaths: vi.fn(() => ({
|
getDataPaths: vi.fn(() => ({
|
||||||
database: '/mock/data/bds.db',
|
database: '/mock/data/bds.db',
|
||||||
posts: '/mock/data/posts',
|
posts: '/mock/data/posts',
|
||||||
@@ -271,6 +294,7 @@ describe('IPC Handlers', () => {
|
|||||||
// Clear all mocks
|
// Clear all mocks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
registeredHandlers.clear();
|
registeredHandlers.clear();
|
||||||
|
mockSettingsStore.clear();
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
|
|
||||||
// Import and register handlers fresh for each test
|
// Import and register handlers fresh for each test
|
||||||
@@ -1544,6 +1568,175 @@ describe('IPC Handlers', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should generate rss and atom feeds with newest maxPostsPerPage published snapshots', async () => {
|
||||||
|
const mockProject = createMockProject({
|
||||||
|
id: 'test-project',
|
||||||
|
dataPath: '/mock/data',
|
||||||
|
});
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||||
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||||
|
name: 'Test Project',
|
||||||
|
description: 'Test Description',
|
||||||
|
publicUrl: 'https://blog.example.com',
|
||||||
|
maxPostsPerPage: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||||
|
if (filter.status === 'published') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'post-new',
|
||||||
|
projectId: 'test-project',
|
||||||
|
title: 'Newest <Post>',
|
||||||
|
slug: 'newest-post',
|
||||||
|
excerpt: '',
|
||||||
|
content: '',
|
||||||
|
status: 'published',
|
||||||
|
createdAt: new Date('2024-03-05T10:00:00Z'),
|
||||||
|
updatedAt: new Date('2024-03-05T11:00:00Z'),
|
||||||
|
publishedAt: new Date('2024-03-05T10:00:00Z'),
|
||||||
|
tags: ['tag-one'],
|
||||||
|
categories: ['category-one'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'post-old',
|
||||||
|
projectId: 'test-project',
|
||||||
|
title: 'Old Post',
|
||||||
|
slug: 'old-post',
|
||||||
|
excerpt: '',
|
||||||
|
content: '',
|
||||||
|
status: 'published',
|
||||||
|
createdAt: new Date('2024-02-01T10:00:00Z'),
|
||||||
|
updatedAt: new Date('2024-02-01T11:00:00Z'),
|
||||||
|
publishedAt: new Date('2024-02-01T10:00:00Z'),
|
||||||
|
tags: ['tag-two'],
|
||||||
|
categories: ['category-two'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (filter.status === 'draft') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
|
||||||
|
if (id !== 'post-new') return null;
|
||||||
|
return {
|
||||||
|
id: 'post-new',
|
||||||
|
projectId: 'test-project',
|
||||||
|
title: 'Newest <Post>',
|
||||||
|
slug: 'newest-post',
|
||||||
|
excerpt: undefined,
|
||||||
|
content: 'First paragraph with <tag> & symbol.\n\nSecond paragraph.',
|
||||||
|
status: 'published',
|
||||||
|
author: 'Author A',
|
||||||
|
createdAt: new Date('2024-03-05T10:00:00Z'),
|
||||||
|
updatedAt: new Date('2024-03-05T11:00:00Z'),
|
||||||
|
publishedAt: new Date('2024-03-05T10:00:00Z'),
|
||||||
|
tags: ['tag-one'],
|
||||||
|
categories: ['category-one'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { writeFile, mkdir } = await import('fs/promises');
|
||||||
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
return await task.execute(onProgress);
|
||||||
|
});
|
||||||
|
|
||||||
|
await invokeHandler('blog:generateSitemap');
|
||||||
|
|
||||||
|
const writtenFiles = vi.mocked(writeFile).mock.calls.map(([filePath, body]) => ({
|
||||||
|
filePath: filePath as string,
|
||||||
|
body: body as string,
|
||||||
|
}));
|
||||||
|
const rss = writtenFiles.find((entry) => entry.filePath.endsWith('/rss.xml'))?.body;
|
||||||
|
const atom = writtenFiles.find((entry) => entry.filePath.endsWith('/atom.xml'))?.body;
|
||||||
|
|
||||||
|
expect(rss).toBeTruthy();
|
||||||
|
expect(atom).toBeTruthy();
|
||||||
|
expect(rss).toContain('newest-post');
|
||||||
|
expect(rss).not.toContain('old-post');
|
||||||
|
expect(atom).toContain('newest-post');
|
||||||
|
expect(atom).not.toContain('old-post');
|
||||||
|
expect(rss).toContain('Newest <Post>');
|
||||||
|
expect(rss).toContain('First paragraph with <tag> & symbol.');
|
||||||
|
expect(atom).toContain('<category term="tag-one"');
|
||||||
|
expect(atom).toContain('<category term="category-one"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip rewriting sitemap and feeds when content hash is unchanged', async () => {
|
||||||
|
const mockProject = createMockProject({
|
||||||
|
id: 'test-project',
|
||||||
|
dataPath: '/mock/data',
|
||||||
|
});
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||||
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||||
|
name: 'Test Project',
|
||||||
|
publicUrl: 'https://blog.example.com',
|
||||||
|
maxPostsPerPage: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||||
|
if (filter.status === 'published') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'post-1',
|
||||||
|
projectId: 'test-project',
|
||||||
|
title: 'Hash test',
|
||||||
|
slug: 'hash-test',
|
||||||
|
excerpt: 'Hash excerpt',
|
||||||
|
content: '',
|
||||||
|
status: 'published',
|
||||||
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||||
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||||
|
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
||||||
|
tags: [],
|
||||||
|
categories: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (filter.status === 'draft') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockPostEngine.getPublishedVersion.mockImplementation(async () => ({
|
||||||
|
id: 'post-1',
|
||||||
|
projectId: 'test-project',
|
||||||
|
title: 'Hash test',
|
||||||
|
slug: 'hash-test',
|
||||||
|
excerpt: 'Hash excerpt',
|
||||||
|
content: 'Hash content',
|
||||||
|
status: 'published',
|
||||||
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||||
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||||
|
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
||||||
|
tags: [],
|
||||||
|
categories: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { writeFile, mkdir } = await import('fs/promises');
|
||||||
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
return await task.execute(onProgress);
|
||||||
|
});
|
||||||
|
|
||||||
|
await invokeHandler('blog:generateSitemap');
|
||||||
|
vi.mocked(writeFile).mockClear();
|
||||||
|
await invokeHandler('blog:generateSitemap');
|
||||||
|
|
||||||
|
expect(writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw error when no active project', async () => {
|
it('should throw error when no active project', async () => {
|
||||||
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ describe('documentation structure and presentation', () => {
|
|||||||
const docPath = path.resolve(process.cwd(), 'DOCUMENTATION.md');
|
const docPath = path.resolve(process.cwd(), 'DOCUMENTATION.md');
|
||||||
const markdown = readFileSync(docPath, 'utf8');
|
const markdown = readFileSync(docPath, 'utf8');
|
||||||
|
|
||||||
expect(markdown).toContain('## Index');
|
expect(markdown).toContain('## In this article');
|
||||||
expect(markdown).not.toMatch(/^\s{2,}-\s+\[/m);
|
expect(markdown).not.toMatch(/^\s{2,}-\s+\[/m);
|
||||||
expect(markdown).not.toMatch(/^##\s+\d+\)/m);
|
expect(markdown).not.toMatch(/^##\s+\d+\)/m);
|
||||||
expect(markdown).toMatch(/^###\s+1\)/m);
|
expect(markdown).toContain('[Who this guide is for](#who-this-guide-is-for)');
|
||||||
|
expect(markdown).toContain('## Who this guide is for');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scopes Pico conditional styling to the documentation view', () => {
|
it('scopes Pico conditional styling to the documentation view', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user