feat: more incremental reposting behaviour
This commit is contained in:
@@ -970,46 +970,80 @@ export class BlogGenerationEngine {
|
|||||||
|
|
||||||
onProgress(10, 'Planning validation apply steps...');
|
onProgress(10, 'Planning validation apply steps...');
|
||||||
|
|
||||||
const sections = new Set<BlogGenerationSection>();
|
const requestedCategories = new Set<string>();
|
||||||
|
const requestedTags = new Set<string>();
|
||||||
|
const requestedYears = new Set<number>();
|
||||||
|
const requestedYearMonths = new Set<string>();
|
||||||
|
const requestedYearMonthDays = new Set<string>();
|
||||||
|
const requestedPostRoutes: Array<{ year: number; month: number; day: number; slug: string }> = [];
|
||||||
|
const requestedPageSlugs = new Set<string>();
|
||||||
|
let requestRootRoutes = false;
|
||||||
|
let requiresFallbackSectionRender = false;
|
||||||
|
|
||||||
|
const decodePathSegment = (value: string): string => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const missingPath of missingPaths) {
|
for (const missingPath of missingPaths) {
|
||||||
const normalizedPath = normalizeUrlPath(missingPath);
|
const normalizedPath = normalizeUrlPath(missingPath);
|
||||||
|
|
||||||
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
|
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
|
||||||
sections.add('core');
|
requestRootRoutes = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/category\//.test(normalizedPath)) {
|
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
|
||||||
sections.add('category');
|
if (categoryMatch) {
|
||||||
|
requestedCategories.add(decodePathSegment(categoryMatch[1]));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/tag\//.test(normalizedPath)) {
|
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
|
||||||
sections.add('tag');
|
if (tagMatch) {
|
||||||
|
requestedTags.add(decodePathSegment(tagMatch[1]));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/\d{4}\/\d{2}\/\d{2}\/[^/]+$/.test(normalizedPath)) {
|
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
|
||||||
sections.add('single');
|
if (singleMatch) {
|
||||||
|
requestedPostRoutes.push({
|
||||||
|
year: Number(singleMatch[1]),
|
||||||
|
month: Number(singleMatch[2]),
|
||||||
|
day: Number(singleMatch[3]),
|
||||||
|
slug: decodePathSegment(singleMatch[4]),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/\d{4}(?:\/\d{2}(?:\/\d{2})?)?(?:\/page\/\d+)?$/.test(normalizedPath)) {
|
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
|
||||||
sections.add('date');
|
if (yearMatch) {
|
||||||
|
requestedYears.add(Number(yearMatch[1]));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/[^/]+$/.test(normalizedPath)) {
|
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||||
sections.add('core');
|
if (monthMatch) {
|
||||||
|
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
sections.clear();
|
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||||
sections.add('core');
|
if (dayMatch) {
|
||||||
sections.add('single');
|
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
|
||||||
sections.add('category');
|
continue;
|
||||||
sections.add('tag');
|
}
|
||||||
sections.add('date');
|
|
||||||
|
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
|
||||||
|
if (pageMatch) {
|
||||||
|
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
requiresFallbackSectionRender = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,35 +1092,322 @@ export class BlogGenerationEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let renderedUrlCount = 0;
|
let renderedUrlCount = 0;
|
||||||
const sectionExecutionOrder: BlogGenerationSection[] = ['category', 'tag', 'date', 'core', 'single'];
|
|
||||||
const orderedSections = sectionExecutionOrder.filter((section) => sections.has(section));
|
|
||||||
|
|
||||||
if (orderedSections.length > 0) {
|
|
||||||
for (let index = 0; index < orderedSections.length; index += 1) {
|
|
||||||
const section = orderedSections[index];
|
|
||||||
const sectionLabel = section === 'category'
|
|
||||||
? 'categories'
|
|
||||||
: section === 'tag'
|
|
||||||
? 'tags'
|
|
||||||
: section === 'date'
|
|
||||||
? 'calendar'
|
|
||||||
: section;
|
|
||||||
|
|
||||||
onProgress(50 + Math.floor((index / orderedSections.length) * 40), `Rendering missing ${sectionLabel} routes...`);
|
|
||||||
|
|
||||||
|
if (requiresFallbackSectionRender) {
|
||||||
|
onProgress(50, 'Rendering missing routes (fallback section mode)...');
|
||||||
|
const sectionExecutionOrder: BlogGenerationSection[] = ['category', 'tag', 'date', 'core', 'single'];
|
||||||
|
for (let index = 0; index < sectionExecutionOrder.length; index += 1) {
|
||||||
|
const section = sectionExecutionOrder[index];
|
||||||
const generationResult = await this.generate({
|
const generationResult = await this.generate({
|
||||||
...options,
|
...options,
|
||||||
maxPostsPerPage: options.maxPostsPerPage,
|
maxPostsPerPage: options.maxPostsPerPage,
|
||||||
sections: [section],
|
sections: [section],
|
||||||
}, (progress, message) => {
|
}, (progress, message) => {
|
||||||
const base = 50 + Math.floor((index / orderedSections.length) * 40);
|
const base = 50 + Math.floor((index / sectionExecutionOrder.length) * 40);
|
||||||
const span = Math.max(1, Math.floor(40 / orderedSections.length));
|
const span = Math.max(1, Math.floor(40 / sectionExecutionOrder.length));
|
||||||
const mapped = base + Math.floor((progress / 100) * span);
|
const mapped = base + Math.floor((progress / 100) * span);
|
||||||
onProgress(Math.min(90, mapped), message || `Rendering ${sectionLabel} routes...`);
|
onProgress(Math.min(90, mapped), message || `Rendering ${section} routes...`);
|
||||||
});
|
});
|
||||||
|
|
||||||
renderedUrlCount += generationResult.pagesGenerated;
|
renderedUrlCount += generationResult.pagesGenerated;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const categorySettings = resolveCategorySettings(options.categorySettings);
|
||||||
|
const listExcludedCategories = Object.entries(categorySettings)
|
||||||
|
.filter(([, settings]) => settings.renderInLists === false)
|
||||||
|
.map(([category]) => category);
|
||||||
|
|
||||||
|
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||||
|
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
|
||||||
|
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
|
||||||
|
const publishedListCandidates = await this.postEngine.getPostsFiltered({
|
||||||
|
status: 'published',
|
||||||
|
excludeCategories: listExcludedCategories,
|
||||||
|
});
|
||||||
|
const draftListCandidates = await this.postEngine.getPostsFiltered({
|
||||||
|
status: 'draft',
|
||||||
|
excludeCategories: listExcludedCategories,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 publishedListSnapshots = await Promise.all(
|
||||||
|
publishedListCandidates.map(async (post) => {
|
||||||
|
const snapshot = await this.postEngine.getPublishedVersion(post.id);
|
||||||
|
return snapshot || post;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const draftListPublishedSnapshots = await Promise.all(
|
||||||
|
draftListCandidates.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 publishedListPostById = new Map<string, PostData>();
|
||||||
|
for (const post of publishedListSnapshots) {
|
||||||
|
publishedListPostById.set(post.id, post);
|
||||||
|
}
|
||||||
|
for (const snapshot of draftListPublishedSnapshots) {
|
||||||
|
if (snapshot) {
|
||||||
|
publishedListPostById.set(snapshot.id, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const publishedListPosts = Array.from(publishedListPostById.values())
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
|
||||||
|
const allCategories = new Set<string>();
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
const years = new Map<number, Date>();
|
||||||
|
const yearMonths = new Map<string, Date>();
|
||||||
|
const yearMonthDays = new Map<string, Date>();
|
||||||
|
|
||||||
|
for (const post of publishedListPosts) {
|
||||||
|
for (const category of post.categories || []) allCategories.add(category);
|
||||||
|
for (const tag of post.tags || []) allTags.add(tag);
|
||||||
|
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const updatedAt = post.updatedAt;
|
||||||
|
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 (!years.has(year) || updatedAt > years.get(year)!) {
|
||||||
|
years.set(year, updatedAt);
|
||||||
|
}
|
||||||
|
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
||||||
|
yearMonths.set(ymKey, updatedAt);
|
||||||
|
}
|
||||||
|
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
||||||
|
yearMonthDays.set(ymdKey, updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPostIds = new Set<string>();
|
||||||
|
for (const requestedRoute of requestedPostRoutes) {
|
||||||
|
const routePost = publishedPosts.find((post) => {
|
||||||
|
if (post.slug !== requestedRoute.slug) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
return createdAt.getFullYear() === requestedRoute.year
|
||||||
|
&& (createdAt.getMonth() + 1) === requestedRoute.month
|
||||||
|
&& createdAt.getDate() === requestedRoute.day;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (routePost) {
|
||||||
|
requestedPostIds.add(routePost.id);
|
||||||
|
for (const category of routePost.categories || []) {
|
||||||
|
requestedCategories.add(category);
|
||||||
|
}
|
||||||
|
for (const tag of routePost.tags || []) {
|
||||||
|
requestedTags.add(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = resolvePostCreatedAt(routePost);
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
requestedYears.add(year);
|
||||||
|
requestedYearMonths.add(`${year}/${month}`);
|
||||||
|
requestedYearMonthDays.add(`${year}/${month}/${day}`);
|
||||||
|
} else {
|
||||||
|
requestedYears.add(requestedRoute.year);
|
||||||
|
requestedYearMonths.add(`${requestedRoute.year}/${String(requestedRoute.month).padStart(2, '0')}`);
|
||||||
|
requestedYearMonthDays.add(`${requestedRoute.year}/${String(requestedRoute.month).padStart(2, '0')}/${String(requestedRoute.day).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const year of Array.from(requestedYears.values())) {
|
||||||
|
for (const ym of yearMonths.keys()) {
|
||||||
|
if (ym.startsWith(`${year}/`)) {
|
||||||
|
requestedYearMonths.add(ym);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ym of Array.from(requestedYearMonths.values())) {
|
||||||
|
for (const ymd of yearMonthDays.keys()) {
|
||||||
|
if (ymd.startsWith(`${ym}/`)) {
|
||||||
|
requestedYearMonthDays.add(ymd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [yearStr] = ym.split('/');
|
||||||
|
requestedYears.add(Number(yearStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ymd of Array.from(requestedYearMonthDays.values())) {
|
||||||
|
const [yearStr, monthStr] = ymd.split('/');
|
||||||
|
requestedYears.add(Number(yearStr));
|
||||||
|
requestedYearMonths.add(`${yearStr}/${monthStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlDir = path.join(options.dataDir, 'html');
|
||||||
|
await fs.mkdir(htmlDir, { recursive: true });
|
||||||
|
|
||||||
|
const pageTitle = options.pageTitle || options.projectName;
|
||||||
|
const language = options.language || 'en';
|
||||||
|
const pageContext = {
|
||||||
|
page_title: pageTitle,
|
||||||
|
language,
|
||||||
|
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
|
||||||
|
};
|
||||||
|
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine);
|
||||||
|
const rewriteContext = this.buildHtmlRewriteContext(publishedPosts);
|
||||||
|
const onPageGenerated = (_message: string) => {
|
||||||
|
// no-op for applyValidation
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestedCategorySet = new Set(
|
||||||
|
Array.from(requestedCategories.values()).filter((category) => allCategories.has(category)),
|
||||||
|
);
|
||||||
|
const requestedTagSet = new Set(
|
||||||
|
Array.from(requestedTags.values()).filter((tag) => allTags.has(tag)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestedSinglePosts = publishedPosts.filter((post) => requestedPostIds.has(post.id));
|
||||||
|
const requestedPagePosts = publishedPosts.filter((post) => {
|
||||||
|
if (!requestedPageSlugs.has(post.slug)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const categories = Array.isArray(post.categories) ? post.categories : [];
|
||||||
|
return categories.includes('page');
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestedYearsMap = new Map<number, Date>();
|
||||||
|
for (const year of requestedYears) {
|
||||||
|
const lastmod = years.get(year);
|
||||||
|
if (lastmod) {
|
||||||
|
requestedYearsMap.set(year, lastmod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedYearMonthsMap = new Map<string, Date>();
|
||||||
|
for (const ym of requestedYearMonths) {
|
||||||
|
const lastmod = yearMonths.get(ym);
|
||||||
|
if (lastmod) {
|
||||||
|
requestedYearMonthsMap.set(ym, lastmod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedYearMonthDaysMap = new Map<string, Date>();
|
||||||
|
for (const ymd of requestedYearMonthDays) {
|
||||||
|
const lastmod = yearMonthDays.get(ymd);
|
||||||
|
if (lastmod) {
|
||||||
|
requestedYearMonthDaysMap.set(ymd, lastmod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(
|
||||||
|
48,
|
||||||
|
`Targeted rerender plan: singles=${requestedSinglePosts.length}, categories=${requestedCategorySet.size}, tags=${requestedTagSet.size}, years=${requestedYearsMap.size}, months=${requestedYearMonthsMap.size}, days=${requestedYearMonthDaysMap.size}, root=${requestRootRoutes ? 1 : 0}, pages=${requestedPagePosts.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
onProgress(50, 'Rendering targeted missing routes...');
|
||||||
|
|
||||||
|
if (requestRootRoutes) {
|
||||||
|
renderedUrlCount += await this.generateRootPages(
|
||||||
|
options.projectId,
|
||||||
|
publishedListPosts,
|
||||||
|
rewriteContext,
|
||||||
|
maxPostsPerPage,
|
||||||
|
htmlDir,
|
||||||
|
pageContext,
|
||||||
|
pageRenderer,
|
||||||
|
categorySettings,
|
||||||
|
onPageGenerated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedPagePosts.length > 0) {
|
||||||
|
renderedUrlCount += await this.generatePageRoutes(
|
||||||
|
options.projectId,
|
||||||
|
requestedPagePosts,
|
||||||
|
rewriteContext,
|
||||||
|
htmlDir,
|
||||||
|
pageContext,
|
||||||
|
pageRenderer,
|
||||||
|
onPageGenerated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedCategorySet.size > 0) {
|
||||||
|
renderedUrlCount += await this.generateCategoryPages(
|
||||||
|
options.projectId,
|
||||||
|
publishedListPosts,
|
||||||
|
requestedCategorySet,
|
||||||
|
rewriteContext,
|
||||||
|
maxPostsPerPage,
|
||||||
|
htmlDir,
|
||||||
|
pageContext,
|
||||||
|
pageRenderer,
|
||||||
|
categorySettings,
|
||||||
|
onPageGenerated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedTagSet.size > 0) {
|
||||||
|
renderedUrlCount += await this.generateTagPages(
|
||||||
|
options.projectId,
|
||||||
|
publishedListPosts,
|
||||||
|
requestedTagSet,
|
||||||
|
rewriteContext,
|
||||||
|
maxPostsPerPage,
|
||||||
|
htmlDir,
|
||||||
|
pageContext,
|
||||||
|
pageRenderer,
|
||||||
|
categorySettings,
|
||||||
|
onPageGenerated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedSinglePosts.length > 0) {
|
||||||
|
renderedUrlCount += await this.generateSinglePostPages(
|
||||||
|
options.projectId,
|
||||||
|
requestedSinglePosts,
|
||||||
|
rewriteContext,
|
||||||
|
htmlDir,
|
||||||
|
pageContext,
|
||||||
|
pageRenderer,
|
||||||
|
onPageGenerated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedYearsMap.size > 0 || requestedYearMonthsMap.size > 0 || requestedYearMonthDaysMap.size > 0) {
|
||||||
|
renderedUrlCount += await this.generateDateArchivePages(
|
||||||
|
options.projectId,
|
||||||
|
publishedListPosts,
|
||||||
|
requestedYearsMap,
|
||||||
|
requestedYearMonthsMap,
|
||||||
|
requestedYearMonthDaysMap,
|
||||||
|
rewriteContext,
|
||||||
|
maxPostsPerPage,
|
||||||
|
htmlDir,
|
||||||
|
pageContext,
|
||||||
|
pageRenderer,
|
||||||
|
categorySettings,
|
||||||
|
onPageGenerated,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress(100, `Apply complete (${deletedUrlCount} deleted, ${renderedUrlCount} rendered)`);
|
onProgress(100, `Apply complete (${deletedUrlCount} deleted, ${renderedUrlCount} rendered)`);
|
||||||
|
|||||||
@@ -607,9 +607,10 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies validation by deleting first, then rendering category, tag, and date sections', async () => {
|
it('applies validation by generating only missing category and tag routes', async () => {
|
||||||
const posts = [
|
const posts = [
|
||||||
makePost({ id: '1', slug: 'ordered-post', categories: ['news'], tags: ['ordered-tag'], createdAt: new Date('2025-01-15T10:00:00Z') }),
|
makePost({ id: '1', slug: 'ordered-post', categories: ['news'], tags: ['ordered-tag'], createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||||
|
makePost({ id: '2', slug: 'other-post', categories: ['other-category'], tags: ['other-tag'], createdAt: new Date('2024-12-20T10:00:00Z') }),
|
||||||
];
|
];
|
||||||
setupPosts(posts);
|
setupPosts(posts);
|
||||||
|
|
||||||
@@ -619,31 +620,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
const engine = new BlogGenerationEngine();
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
const callOrder: string[] = [];
|
const generateSpy = vi.spyOn(engine, 'generate');
|
||||||
const generateSpy = vi.spyOn(engine, 'generate').mockImplementation(async (opts) => {
|
|
||||||
const staleExistsAtRenderTime = await fileExists(path.join(tempDir, 'html', 'stale', 'index.html'));
|
|
||||||
expect(staleExistsAtRenderTime).toBe(false);
|
|
||||||
callOrder.push((opts.sections || []).join(','));
|
|
||||||
return {
|
|
||||||
path: path.join(tempDir, 'html', 'sitemap.xml'),
|
|
||||||
urlCount: 0,
|
|
||||||
postCount: 0,
|
|
||||||
feedPostCount: 0,
|
|
||||||
tagCount: 0,
|
|
||||||
categoryCount: 0,
|
|
||||||
archiveCount: 0,
|
|
||||||
pagesGenerated: 1,
|
|
||||||
feeds: {
|
|
||||||
rssPath: path.join(tempDir, 'html', 'rss.xml'),
|
|
||||||
atomPath: path.join(tempDir, 'html', 'atom.xml'),
|
|
||||||
},
|
|
||||||
changed: {
|
|
||||||
sitemap: false,
|
|
||||||
rss: false,
|
|
||||||
atom: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await engine.applyValidation({
|
const result = await engine.applyValidation({
|
||||||
projectId: 'test',
|
projectId: 'test',
|
||||||
@@ -653,18 +630,253 @@ describe('BlogGenerationEngine', () => {
|
|||||||
}, {
|
}, {
|
||||||
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||||
sitemapChanged: false,
|
sitemapChanged: false,
|
||||||
missingUrlPaths: ['/category/news', '/tag/ordered-tag', '/2025/01'],
|
missingUrlPaths: ['/category/news', '/tag/ordered-tag'],
|
||||||
extraUrlPaths: ['/stale'],
|
extraUrlPaths: ['/stale'],
|
||||||
expectedUrlCount: 3,
|
expectedUrlCount: 2,
|
||||||
existingHtmlUrlCount: 1,
|
existingHtmlUrlCount: 1,
|
||||||
}, vi.fn());
|
}, vi.fn());
|
||||||
|
|
||||||
expect(result.deletedUrlCount).toBe(1);
|
expect(result.deletedUrlCount).toBe(1);
|
||||||
expect(callOrder).toEqual(['category', 'tag', 'date']);
|
expect(generateSpy).not.toHaveBeenCalled();
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'category', 'news', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'ordered-tag', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'category', 'other-category', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'other-tag', 'index.html'))).toBe(false);
|
||||||
|
|
||||||
generateSpy.mockRestore();
|
generateSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies validation for a missing month by generating that month and its day archives only', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', slug: 'jan-post', createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||||
|
makePost({ id: '2', slug: 'feb-post', createdAt: new Date('2025-02-20T10:00:00Z') }),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, {
|
||||||
|
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||||
|
sitemapChanged: false,
|
||||||
|
missingUrlPaths: ['/2025/01'],
|
||||||
|
extraUrlPaths: [],
|
||||||
|
expectedUrlCount: 1,
|
||||||
|
existingHtmlUrlCount: 0,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', '20', 'index.html'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies validation for a missing year by generating that year and nested month/day archives only', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', slug: 'year-2025', createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||||
|
makePost({ id: '2', slug: 'year-2024', createdAt: new Date('2024-02-20T10:00:00Z') }),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, {
|
||||||
|
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||||
|
sitemapChanged: false,
|
||||||
|
missingUrlPaths: ['/2025'],
|
||||||
|
extraUrlPaths: [],
|
||||||
|
expectedUrlCount: 1,
|
||||||
|
existingHtmlUrlCount: 0,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2024', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2024', '02', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2024', '02', '20', 'index.html'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies validation for a missing post by generating post and its date archives only', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', slug: 'target-post', createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||||
|
makePost({ id: '2', slug: 'other-year-post', createdAt: new Date('2024-02-20T10:00:00Z') }),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, {
|
||||||
|
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||||
|
sitemapChanged: false,
|
||||||
|
missingUrlPaths: ['/2025/01/15/target-post'],
|
||||||
|
extraUrlPaths: [],
|
||||||
|
expectedUrlCount: 1,
|
||||||
|
existingHtmlUrlCount: 0,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'target-post', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2024', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2024', '02', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2024', '02', '20', 'index.html'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges date archive renders when multiple missing posts share the same date lineage', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', slug: 'target-post-1', createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||||
|
makePost({ id: '2', slug: 'target-post-2', createdAt: new Date('2025-01-15T11:00:00Z') }),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');
|
||||||
|
|
||||||
|
await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, {
|
||||||
|
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||||
|
sitemapChanged: false,
|
||||||
|
missingUrlPaths: ['/2025/01/15/target-post-1', '/2025/01/15/target-post-2'],
|
||||||
|
extraUrlPaths: [],
|
||||||
|
expectedUrlCount: 2,
|
||||||
|
existingHtmlUrlCount: 0,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
const dateCalls = renderPostListSpy.mock.calls
|
||||||
|
.map(([, , renderOptions]) => renderOptions)
|
||||||
|
.filter((renderOptions) => renderOptions?.routeKind === 'date');
|
||||||
|
|
||||||
|
const yearCalls = dateCalls.filter((call) => call.archiveContext?.kind === 'year' && call.archiveContext?.year === 2025);
|
||||||
|
const monthCalls = dateCalls.filter((call) => call.archiveContext?.kind === 'month' && call.archiveContext?.year === 2025 && call.archiveContext?.month === 1);
|
||||||
|
const dayCalls = dateCalls.filter((call) => call.archiveContext?.kind === 'day' && call.archiveContext?.year === 2025 && call.archiveContext?.month === 1 && call.archiveContext?.day === 15);
|
||||||
|
|
||||||
|
expect(yearCalls).toHaveLength(1);
|
||||||
|
expect(monthCalls).toHaveLength(1);
|
||||||
|
expect(dayCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
renderPostListSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies validation for a missing post by rerendering its categories and tags', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'cat-tag-post',
|
||||||
|
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['alpha'],
|
||||||
|
}),
|
||||||
|
makePost({
|
||||||
|
id: '2',
|
||||||
|
slug: 'other-post',
|
||||||
|
createdAt: new Date('2024-02-20T10:00:00Z'),
|
||||||
|
categories: ['other-category'],
|
||||||
|
tags: ['other-tag'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, {
|
||||||
|
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||||
|
sitemapChanged: false,
|
||||||
|
missingUrlPaths: ['/2025/01/15/cat-tag-post'],
|
||||||
|
extraUrlPaths: [],
|
||||||
|
expectedUrlCount: 1,
|
||||||
|
existingHtmlUrlCount: 0,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'category', 'news', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'alpha', 'index.html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'category', 'other-category', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'other-tag', 'index.html'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates category and tag rerenders when multiple missing posts share them', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'shared-1',
|
||||||
|
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['alpha'],
|
||||||
|
}),
|
||||||
|
makePost({
|
||||||
|
id: '2',
|
||||||
|
slug: 'shared-2',
|
||||||
|
createdAt: new Date('2025-01-16T10:00:00Z'),
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['alpha'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const { PageRenderer } = await import('../../src/main/engine/PageRenderer');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
const renderPostListSpy = vi.spyOn(PageRenderer.prototype, 'renderPostList');
|
||||||
|
|
||||||
|
await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, {
|
||||||
|
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||||
|
sitemapChanged: false,
|
||||||
|
missingUrlPaths: ['/2025/01/15/shared-1', '/2025/01/16/shared-2'],
|
||||||
|
extraUrlPaths: [],
|
||||||
|
expectedUrlCount: 2,
|
||||||
|
existingHtmlUrlCount: 0,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
const categoryCalls = renderPostListSpy.mock.calls
|
||||||
|
.map(([, , renderOptions]) => renderOptions)
|
||||||
|
.filter((renderOptions) => renderOptions?.archiveContext?.kind === 'category' && renderOptions?.archiveContext?.name === 'news');
|
||||||
|
const tagCalls = renderPostListSpy.mock.calls
|
||||||
|
.map(([, , renderOptions]) => renderOptions)
|
||||||
|
.filter((renderOptions) => renderOptions?.archiveContext?.kind === 'tag' && renderOptions?.archiveContext?.name === 'alpha');
|
||||||
|
|
||||||
|
expect(categoryCalls).toHaveLength(1);
|
||||||
|
expect(tagCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
renderPostListSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('generates HTML that references local assets not CDN', async () => {
|
it('generates HTML that references local assets not CDN', async () => {
|
||||||
const posts = [makePost({ id: '1', slug: 'test' })];
|
const posts = [makePost({ id: '1', slug: 'test' })];
|
||||||
await generate(posts);
|
await generate(posts);
|
||||||
|
|||||||
Reference in New Issue
Block a user