Feat/webworker for incremental render (#51)
* feat: web worker for incremental render * feat: optimizing incremental render for date archives * feat: more work on web worker * fix: blogmark process handled defaulting wrong --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
208
src/main/engine/ApplyValidationWorkerService.ts
Normal file
208
src/main/engine/ApplyValidationWorkerService.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Builds targeted worker tasks for the applyValidation flow.
|
||||||
|
*
|
||||||
|
* Takes a targeted validation plan and produces GenerationWorkerTask[]
|
||||||
|
* scoped to only the affected sections (categories, tags, dates, singles, core).
|
||||||
|
* This gives applyValidation the same worker-pool parallelism as full generation
|
||||||
|
* while limiting the volume to only what's needed.
|
||||||
|
*/
|
||||||
|
import type { PostData } from './PostEngine';
|
||||||
|
import type { GenerationPostIndex } from './GenerationPostIndexService';
|
||||||
|
import type { TargetedValidationPlan } from './ValidationApplyPlannerService';
|
||||||
|
import type {
|
||||||
|
GenerationWorkerTask,
|
||||||
|
SerializedPostData,
|
||||||
|
SerializedMediaData,
|
||||||
|
SerializedBlogGenerationOptions,
|
||||||
|
} from './GenerationWorkerData';
|
||||||
|
import {
|
||||||
|
serializePostData,
|
||||||
|
serializePostMap,
|
||||||
|
serializeDateMap,
|
||||||
|
} from './GenerationWorkerData';
|
||||||
|
|
||||||
|
export interface ApplyValidationWorkerParams {
|
||||||
|
options: SerializedBlogGenerationOptions;
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
htmlDir: string;
|
||||||
|
mediaItems: SerializedMediaData[];
|
||||||
|
backlinksRecord: Record<string, Array<{ id: string; title: string; slug: string }>>;
|
||||||
|
hashMapEntries: Array<[string, string]>;
|
||||||
|
postFilePathEntries: Array<[string, string]>;
|
||||||
|
postMediaLinksEntries: Array<[string, Array<{ mediaId: string; sortOrder: number }>]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyValidationLanguageParams {
|
||||||
|
targetedPlan: TargetedValidationPlan;
|
||||||
|
publishedRoutePosts: PostData[];
|
||||||
|
publishedListPosts: PostData[];
|
||||||
|
generationPostIndex: GenerationPostIndex;
|
||||||
|
years: Map<number, Date>;
|
||||||
|
yearMonths: Map<string, Date>;
|
||||||
|
yearMonthDays: Map<string, Date>;
|
||||||
|
languagePrefix?: string;
|
||||||
|
mainLanguage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApplyValidationWorkerTasks(
|
||||||
|
base: ApplyValidationWorkerParams,
|
||||||
|
lang: ApplyValidationLanguageParams,
|
||||||
|
): GenerationWorkerTask[] {
|
||||||
|
const { targetedPlan, publishedRoutePosts, publishedListPosts, generationPostIndex } = lang;
|
||||||
|
|
||||||
|
const serializedRoutePosts = publishedRoutePosts.map(serializePostData);
|
||||||
|
const serializedListPosts = publishedListPosts.map(serializePostData);
|
||||||
|
|
||||||
|
const baseTaskData = {
|
||||||
|
lookupPosts: serializedRoutePosts,
|
||||||
|
mediaItems: base.mediaItems,
|
||||||
|
backlinksMap: base.backlinksRecord,
|
||||||
|
options: lang.languagePrefix
|
||||||
|
? { ...base.options, language: lang.languagePrefix.replace(/^\//, '') }
|
||||||
|
: base.options,
|
||||||
|
maxPostsPerPage: base.maxPostsPerPage,
|
||||||
|
htmlDir: base.htmlDir,
|
||||||
|
hashMapEntries: base.hashMapEntries,
|
||||||
|
postFilePathEntries: base.postFilePathEntries,
|
||||||
|
postMediaLinksEntries: base.postMediaLinksEntries,
|
||||||
|
languagePrefix: lang.languagePrefix,
|
||||||
|
mainLanguage: lang.mainLanguage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasks: GenerationWorkerTask[] = [];
|
||||||
|
let taskCounter = 0;
|
||||||
|
const langSuffix = lang.languagePrefix ? `-${lang.languagePrefix.replace(/^\//, '')}` : '';
|
||||||
|
const nextTaskId = (section: string) => `apply-${section}${langSuffix}-${++taskCounter}`;
|
||||||
|
|
||||||
|
// Core (root + page routes)
|
||||||
|
if (targetedPlan.requestRootRoutes) {
|
||||||
|
tasks.push({
|
||||||
|
...baseTaskData,
|
||||||
|
taskId: nextTaskId('core'),
|
||||||
|
section: 'core',
|
||||||
|
posts: serializedListPosts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single posts
|
||||||
|
if (targetedPlan.requestedPostIds.size > 0) {
|
||||||
|
const requestedSinglePosts = publishedRoutePosts
|
||||||
|
.filter((p) => targetedPlan.requestedPostIds.has(p.id))
|
||||||
|
.map(serializePostData);
|
||||||
|
|
||||||
|
if (requestedSinglePosts.length > 0) {
|
||||||
|
tasks.push({
|
||||||
|
...baseTaskData,
|
||||||
|
taskId: nextTaskId('single'),
|
||||||
|
section: 'single',
|
||||||
|
posts: requestedSinglePosts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-slug posts (separate from singles; they're resolved through core)
|
||||||
|
// Pages are rendered via the 'core' section's generatePageRoutes call,
|
||||||
|
// so they're covered by the requestRootRoutes task above.
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
if (targetedPlan.requestedCategorySet.size > 0) {
|
||||||
|
// Only include the requested categories and their post-index entries
|
||||||
|
const filteredPostsByCategory = new Map<string, PostData[]>();
|
||||||
|
for (const category of targetedPlan.requestedCategorySet) {
|
||||||
|
const posts = generationPostIndex.postsByCategory.get(category);
|
||||||
|
if (posts) {
|
||||||
|
filteredPostsByCategory.set(category, posts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.push({
|
||||||
|
...baseTaskData,
|
||||||
|
taskId: nextTaskId('category'),
|
||||||
|
section: 'category',
|
||||||
|
posts: serializedListPosts,
|
||||||
|
allCategories: Array.from(targetedPlan.requestedCategorySet),
|
||||||
|
postsByCategoryEntries: serializePostMap(filteredPostsByCategory),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (targetedPlan.requestedTagSet.size > 0) {
|
||||||
|
const filteredPostsByTag = new Map<string, PostData[]>();
|
||||||
|
for (const tag of targetedPlan.requestedTagSet) {
|
||||||
|
const posts = generationPostIndex.postsByTag.get(tag);
|
||||||
|
if (posts) {
|
||||||
|
filteredPostsByTag.set(tag, posts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.push({
|
||||||
|
...baseTaskData,
|
||||||
|
taskId: nextTaskId('tag'),
|
||||||
|
section: 'tag',
|
||||||
|
posts: serializedListPosts,
|
||||||
|
allTags: Array.from(targetedPlan.requestedTagSet),
|
||||||
|
postsByTagEntries: serializePostMap(filteredPostsByTag),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date archives
|
||||||
|
const { requestedYears, requestedYearMonths, requestedYearMonthDays } = targetedPlan;
|
||||||
|
const hasDateRequests = requestedYears.size > 0
|
||||||
|
|| requestedYearMonths.size > 0
|
||||||
|
|| requestedYearMonthDays.size > 0;
|
||||||
|
|
||||||
|
if (hasDateRequests) {
|
||||||
|
// Filter archive maps to only the requested keys
|
||||||
|
const filteredYears = new Map<number, Date>();
|
||||||
|
for (const year of requestedYears) {
|
||||||
|
const lastmod = lang.years.get(year);
|
||||||
|
if (lastmod) filteredYears.set(year, lastmod);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredYearMonths = new Map<string, Date>();
|
||||||
|
for (const ym of requestedYearMonths) {
|
||||||
|
const lastmod = lang.yearMonths.get(ym);
|
||||||
|
if (lastmod) filteredYearMonths.set(ym, lastmod);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredYearMonthDays = new Map<string, Date>();
|
||||||
|
for (const ymd of requestedYearMonthDays) {
|
||||||
|
const lastmod = lang.yearMonthDays.get(ymd);
|
||||||
|
if (lastmod) filteredYearMonthDays.set(ymd, lastmod);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter post-index entries to only the requested date keys
|
||||||
|
const filteredPostsByYear = new Map<number, PostData[]>();
|
||||||
|
for (const year of requestedYears) {
|
||||||
|
const posts = generationPostIndex.postsByYear.get(year);
|
||||||
|
if (posts) filteredPostsByYear.set(year, posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPostsByYearMonth = new Map<string, PostData[]>();
|
||||||
|
for (const ym of requestedYearMonths) {
|
||||||
|
const posts = generationPostIndex.postsByYearMonth.get(ym);
|
||||||
|
if (posts) filteredPostsByYearMonth.set(ym, posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPostsByYearMonthDay = new Map<string, PostData[]>();
|
||||||
|
for (const ymd of requestedYearMonthDays) {
|
||||||
|
const posts = generationPostIndex.postsByYearMonthDay.get(ymd);
|
||||||
|
if (posts) filteredPostsByYearMonthDay.set(ymd, posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.push({
|
||||||
|
...baseTaskData,
|
||||||
|
taskId: nextTaskId('date'),
|
||||||
|
section: 'date',
|
||||||
|
posts: serializedListPosts,
|
||||||
|
yearsEntries: serializeDateMap(filteredYears),
|
||||||
|
yearMonthsEntries: serializeDateMap(filteredYearMonths),
|
||||||
|
yearMonthDaysEntries: serializeDateMap(filteredYearMonthDays),
|
||||||
|
postsByYearEntries: serializePostMap(filteredPostsByYear),
|
||||||
|
postsByYearMonthEntries: serializePostMap(filteredPostsByYearMonth),
|
||||||
|
postsByYearMonthDayEntries: serializePostMap(filteredPostsByYearMonthDay),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
@@ -44,9 +44,10 @@ import {
|
|||||||
buildRequestedArchiveMaps,
|
buildRequestedArchiveMaps,
|
||||||
selectRequestedPosts,
|
selectRequestedPosts,
|
||||||
} from './ApplyValidationDataService';
|
} from './ApplyValidationDataService';
|
||||||
|
import { buildApplyValidationWorkerTasks } from './ApplyValidationWorkerService';
|
||||||
import { getGeneratedFileHashRecord } from '../database/generatedFileHashStore';
|
import { getGeneratedFileHashRecord } from '../database/generatedFileHashStore';
|
||||||
import { getAllGeneratedFileHashes, setGeneratedFileHash } from '../database/generatedFileHashStore';
|
import { getAllGeneratedFileHashes, setGeneratedFileHash } from '../database/generatedFileHashStore';
|
||||||
import { GenerationWorkerPool, type WorkerPoolResult } from './GenerationWorkerPool';
|
import { GenerationWorkerPool } from './GenerationWorkerPool';
|
||||||
import {
|
import {
|
||||||
serializePostData,
|
serializePostData,
|
||||||
serializeMediaItem,
|
serializeMediaItem,
|
||||||
@@ -54,7 +55,6 @@ import {
|
|||||||
serializePostMap,
|
serializePostMap,
|
||||||
serializeDateMap,
|
serializeDateMap,
|
||||||
type GenerationWorkerTask,
|
type GenerationWorkerTask,
|
||||||
type SerializedPostData,
|
|
||||||
} from './GenerationWorkerData';
|
} from './GenerationWorkerData';
|
||||||
import { readPostTranslationFile } from './postTranslationFileUtils';
|
import { readPostTranslationFile } from './postTranslationFileUtils';
|
||||||
|
|
||||||
@@ -130,6 +130,34 @@ export interface SiteValidationApplyResult {
|
|||||||
removedEmptyDirCount: number;
|
removedEmptyDirCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApplyValidationPreparation {
|
||||||
|
deletedUrlCount: number;
|
||||||
|
removedEmptyDirCount: number;
|
||||||
|
requiresFallbackSectionRender: boolean;
|
||||||
|
sectionsToRender: BlogGenerationSection[];
|
||||||
|
/** Pre-built worker tasks partitioned by section (worker mode only). */
|
||||||
|
workerTasksBySection: Map<BlogGenerationSection, GenerationWorkerTask[]>;
|
||||||
|
/** Data for main-thread fallback rendering (non-worker mode only). */
|
||||||
|
targetedRenderData?: ApplyValidationTargetedRenderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyValidationTargetedRenderData {
|
||||||
|
options: BlogGenerationOptions;
|
||||||
|
maxPostsPerPage: number;
|
||||||
|
htmlDir: string;
|
||||||
|
publishedPosts: PostData[];
|
||||||
|
publishedListPosts: PostData[];
|
||||||
|
publishedRoutePosts: PostData[];
|
||||||
|
generationPostIndex: GenerationPostIndex;
|
||||||
|
targetedPlan: import('./ValidationApplyPlannerService').TargetedValidationPlan;
|
||||||
|
missingPathPlan: import('./ValidationApplyPlannerService').MissingPathPlan;
|
||||||
|
mainLanguage: string;
|
||||||
|
additionalLanguages: string[];
|
||||||
|
years: Map<number, Date>;
|
||||||
|
yearMonths: Map<string, Date>;
|
||||||
|
yearMonthDays: Map<string, Date>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalendarRegenerationResult {
|
export interface CalendarRegenerationResult {
|
||||||
calendarPath: string;
|
calendarPath: string;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
@@ -1543,15 +1571,55 @@ export class BlogGenerationEngine {
|
|||||||
report: SiteValidationReport,
|
report: SiteValidationReport,
|
||||||
onProgress: (progress: number, message?: string) => void,
|
onProgress: (progress: number, message?: string) => void,
|
||||||
): Promise<SiteValidationApplyResult> {
|
): Promise<SiteValidationApplyResult> {
|
||||||
onProgress(0, 'Applying validation changes...');
|
const preparation = await this.prepareApplyValidation(options, report, (progress, message) => {
|
||||||
|
const mapped = Math.floor((progress / 100) * 45);
|
||||||
|
onProgress(mapped, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
let renderedUrlCount = 0;
|
||||||
|
const sections = preparation.sectionsToRender;
|
||||||
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
const section = sections[i];
|
||||||
|
const sectionCount = await this.applyValidationForSection(options, preparation, section, (progress, message) => {
|
||||||
|
const base = 45 + Math.floor((i / Math.max(1, sections.length)) * 45);
|
||||||
|
const span = Math.max(1, Math.floor(45 / Math.max(1, sections.length)));
|
||||||
|
const mapped = base + Math.floor((progress / 100) * span);
|
||||||
|
onProgress(Math.min(90, mapped), message);
|
||||||
|
});
|
||||||
|
renderedUrlCount += sectionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderedUrlCount > 0 || preparation.deletedUrlCount > 0) {
|
||||||
|
onProgress(90, 'Regenerating calendar data...');
|
||||||
|
await this.regenerateCalendar(options, (progress, message) => {
|
||||||
|
const mappedProgress = 90 + Math.floor((progress / 100) * 9);
|
||||||
|
onProgress(Math.min(99, mappedProgress), message || 'Regenerating calendar data...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(100, `Apply complete (${preparation.deletedUrlCount} deleted, ${renderedUrlCount} rendered)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderedUrlCount,
|
||||||
|
deletedUrlCount: preparation.deletedUrlCount,
|
||||||
|
removedEmptyDirCount: preparation.removedEmptyDirCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-task apply validation: preparation + per-section rendering ───
|
||||||
|
|
||||||
|
async prepareApplyValidation(
|
||||||
|
options: BlogGenerationOptions,
|
||||||
|
report: SiteValidationReport,
|
||||||
|
onProgress: (progress: number, message?: string) => void,
|
||||||
|
): Promise<ApplyValidationPreparation> {
|
||||||
|
onProgress(0, 'Planning validation apply steps...');
|
||||||
|
|
||||||
const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : [];
|
const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : [];
|
||||||
const updatedPostPaths = Array.isArray(report.updatedPostUrlPaths) ? report.updatedPostUrlPaths : [];
|
const updatedPostPaths = Array.isArray(report.updatedPostUrlPaths) ? report.updatedPostUrlPaths : [];
|
||||||
const rerenderPaths = Array.from(new Set([...missingPaths, ...updatedPostPaths]));
|
const rerenderPaths = Array.from(new Set([...missingPaths, ...updatedPostPaths]));
|
||||||
const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : [];
|
const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : [];
|
||||||
|
|
||||||
onProgress(10, 'Planning validation apply steps...');
|
|
||||||
|
|
||||||
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
|
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
|
||||||
const additionalLanguages = (options.blogLanguages ?? [])
|
const additionalLanguages = (options.blogLanguages ?? [])
|
||||||
.map((lang) => lang.trim().toLowerCase())
|
.map((lang) => lang.trim().toLowerCase())
|
||||||
@@ -1567,7 +1635,6 @@ export class BlogGenerationEngine {
|
|||||||
|
|
||||||
const pruneEmptyParents = async (startDir: string): Promise<void> => {
|
const pruneEmptyParents = async (startDir: string): Promise<void> => {
|
||||||
let currentDir = startDir;
|
let currentDir = startDir;
|
||||||
|
|
||||||
while (path.resolve(currentDir) !== path.resolve(htmlDir)) {
|
while (path.resolve(currentDir) !== path.resolve(htmlDir)) {
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
@@ -1575,11 +1642,7 @@ export class BlogGenerationEngine {
|
|||||||
} catch {
|
} catch {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (entries.length > 0) break;
|
||||||
if (entries.length > 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.rm(currentDir, { recursive: true, force: true });
|
await fs.rm(currentDir, { recursive: true, force: true });
|
||||||
removedEmptyDirCount += 1;
|
removedEmptyDirCount += 1;
|
||||||
currentDir = path.dirname(currentDir);
|
currentDir = path.dirname(currentDir);
|
||||||
@@ -1596,182 +1659,122 @@ export class BlogGenerationEngine {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore missing files and continue
|
// ignore missing files and continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extraPaths.length > 0) {
|
if (extraPaths.length > 0) {
|
||||||
const deleteProgress = 20 + Math.floor(((index + 1) / extraPaths.length) * 25);
|
const deleteProgress = 20 + Math.floor(((index + 1) / extraPaths.length) * 25);
|
||||||
onProgress(Math.min(45, deleteProgress), `Deleted ${index + 1}/${extraPaths.length} extra URLs`);
|
onProgress(Math.min(50, deleteProgress), `Deleted ${index + 1}/${extraPaths.length} extra URLs`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let renderedUrlCount = 0;
|
|
||||||
|
|
||||||
if (missingPathPlan.requiresFallbackSectionRender) {
|
if (missingPathPlan.requiresFallbackSectionRender) {
|
||||||
onProgress(50, 'Rendering missing routes (fallback section mode)...');
|
onProgress(100, 'Preparation complete (fallback section mode)');
|
||||||
const sectionExecutionOrder: BlogGenerationSection[] = ['category', 'tag', 'date', 'core', 'single'];
|
return {
|
||||||
for (let index = 0; index < sectionExecutionOrder.length; index += 1) {
|
deletedUrlCount,
|
||||||
const section = sectionExecutionOrder[index];
|
removedEmptyDirCount,
|
||||||
const generationResult = await this.generate({
|
requiresFallbackSectionRender: true,
|
||||||
...options,
|
sectionsToRender: ['category', 'tag', 'date', 'core', 'single'],
|
||||||
maxPostsPerPage: options.maxPostsPerPage,
|
workerTasksBySection: new Map(),
|
||||||
sections: [section],
|
};
|
||||||
}, (progress, message) => {
|
}
|
||||||
const base = 50 + Math.floor((index / sectionExecutionOrder.length) * 40);
|
|
||||||
const span = Math.max(1, Math.floor(40 / sectionExecutionOrder.length));
|
|
||||||
const mapped = base + Math.floor((progress / 100) * span);
|
|
||||||
onProgress(Math.min(90, mapped), message || `Rendering ${section} routes...`);
|
|
||||||
});
|
|
||||||
|
|
||||||
renderedUrlCount += generationResult.pagesGenerated;
|
onProgress(55, 'Loading post data...');
|
||||||
|
|
||||||
|
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
||||||
|
const listExcludedCategories = Object.entries(categorySettings)
|
||||||
|
.filter(([, settings]) => settings.renderInLists === false)
|
||||||
|
.map(([category]) => category);
|
||||||
|
|
||||||
|
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||||
|
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
|
||||||
|
const { routePosts: publishedRoutePosts, translationsByPost } = await this.buildPublishedRoutePosts(publishedPosts);
|
||||||
|
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
|
||||||
|
|
||||||
|
const { allCategories, allTags, years, yearMonths, yearMonthDays } = buildApplyValidationArchives(publishedListPosts);
|
||||||
|
|
||||||
|
const targetedPlan = buildTargetedValidationPlan({
|
||||||
|
initialPlan: missingPathPlan,
|
||||||
|
publishedPosts: publishedRoutePosts,
|
||||||
|
allCategories,
|
||||||
|
allTags,
|
||||||
|
availableYearMonths: yearMonths.keys(),
|
||||||
|
availableYearMonthDays: yearMonthDays.keys(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.mkdir(htmlDir, { recursive: true });
|
||||||
|
|
||||||
|
const useWorkers = !!options.dbPath;
|
||||||
|
|
||||||
|
// Build worker tasks partitioned by section (worker mode only)
|
||||||
|
const workerTasksBySection = new Map<BlogGenerationSection, GenerationWorkerTask[]>();
|
||||||
|
|
||||||
|
if (useWorkers) {
|
||||||
|
onProgress(65, 'Loading media and serializing worker data...');
|
||||||
|
|
||||||
|
const rawMedia = await this.mediaEngine.getAllMedia();
|
||||||
|
const mediaItems = rawMedia.map(serializeMediaItem);
|
||||||
|
|
||||||
|
let backlinksRecord: Record<string, Array<{ id: string; title: string; slug: string }>> = {};
|
||||||
|
if (typeof this.postEngine.getAllBacklinks === 'function') {
|
||||||
|
const blMap = await this.postEngine.getAllBacklinks();
|
||||||
|
for (const [postId, links] of blMap) {
|
||||||
|
backlinksRecord[postId] = links;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
|
||||||
const listExcludedCategories = Object.entries(categorySettings)
|
|
||||||
.filter(([, settings]) => settings.renderInLists === false)
|
|
||||||
.map(([category]) => category);
|
|
||||||
|
|
||||||
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
const serializedOptions = serializeBlogGenerationOptions(options);
|
||||||
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
|
|
||||||
const { routePosts: publishedRoutePosts } = await this.buildPublishedRoutePosts(publishedPosts);
|
|
||||||
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
|
|
||||||
|
|
||||||
const { allCategories, allTags, years, yearMonths, yearMonthDays } = buildApplyValidationArchives(publishedListPosts);
|
let postFilePathEntries: Array<[string, string]> = [];
|
||||||
|
if (typeof this.postEngine.getPublishedPostFilePaths === 'function') {
|
||||||
|
const filePathMap = await this.postEngine.getPublishedPostFilePaths();
|
||||||
|
postFilePathEntries = Array.from(filePathMap);
|
||||||
|
}
|
||||||
|
|
||||||
const targetedPlan = buildTargetedValidationPlan({
|
let postMediaLinksEntries: Array<[string, Array<{ mediaId: string; sortOrder: number }>]> = [];
|
||||||
initialPlan: missingPathPlan,
|
if (typeof this.postMediaEngine.getAllPostMediaLinks === 'function') {
|
||||||
publishedPosts: publishedRoutePosts,
|
const linksMap = await this.postMediaEngine.getAllPostMediaLinks();
|
||||||
allCategories,
|
postMediaLinksEntries = Array.from(linksMap);
|
||||||
allTags,
|
}
|
||||||
availableYearMonths: yearMonths.keys(),
|
|
||||||
availableYearMonthDays: yearMonthDays.keys(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const htmlDir = path.join(options.dataDir, 'html');
|
const generatedHashCache = new Map<string, string | null>();
|
||||||
await fs.mkdir(htmlDir, { recursive: true });
|
const existingHashes = await getAllGeneratedFileHashes(options.projectId);
|
||||||
|
for (const [relativePath, hash] of existingHashes) {
|
||||||
|
generatedHashCache.set(relativePath, hash);
|
||||||
|
}
|
||||||
|
const hashMapEntries: Array<[string, string]> = [];
|
||||||
|
for (const [relativePath, hash] of generatedHashCache) {
|
||||||
|
if (hash !== null) hashMapEntries.push([relativePath, hash]);
|
||||||
|
}
|
||||||
|
|
||||||
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
const mainLangRoutePosts = this.resolvePostsForLanguage(publishedRoutePosts, mainLanguage, translationsByPost, mainLanguage);
|
||||||
options,
|
const mainLangListPosts = this.resolvePostsForLanguage(publishedListPosts, mainLanguage, translationsByPost, mainLanguage);
|
||||||
|
const mainLangPostIndex = buildGenerationPostIndex(mainLangListPosts);
|
||||||
|
|
||||||
|
const baseWorkerParams = {
|
||||||
|
options: serializedOptions,
|
||||||
maxPostsPerPage,
|
maxPostsPerPage,
|
||||||
publishedPostsForLookup: publishedRoutePosts,
|
|
||||||
engines: {
|
|
||||||
postEngine: this.postEngine,
|
|
||||||
mediaEngine: this.mediaEngine,
|
|
||||||
postMediaEngine: this.postMediaEngine,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
|
|
||||||
projectId,
|
|
||||||
htmlDir,
|
htmlDir,
|
||||||
urlPath,
|
mediaItems,
|
||||||
content,
|
backlinksRecord,
|
||||||
refreshHashTimestampOnUnchanged: true,
|
hashMapEntries,
|
||||||
});
|
postFilePathEntries,
|
||||||
const onPageGenerated = (_message: string) => {
|
postMediaLinksEntries,
|
||||||
// no-op for applyValidation
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({
|
onProgress(80, 'Building targeted worker tasks...');
|
||||||
publishedPosts: publishedRoutePosts,
|
|
||||||
requestedPostIds: targetedPlan.requestedPostIds,
|
|
||||||
requestedPageSlugs: targetedPlan.requestedPageSlugs,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { requestedYearsMap, requestedYearMonthsMap, requestedYearMonthDaysMap } = buildRequestedArchiveMaps({
|
const allTasks: GenerationWorkerTask[] = buildApplyValidationWorkerTasks(baseWorkerParams, {
|
||||||
requestedYears: targetedPlan.requestedYears,
|
targetedPlan,
|
||||||
requestedYearMonths: targetedPlan.requestedYearMonths,
|
publishedRoutePosts: mainLangRoutePosts,
|
||||||
requestedYearMonthDays: targetedPlan.requestedYearMonthDays,
|
publishedListPosts: mainLangListPosts,
|
||||||
|
generationPostIndex: mainLangPostIndex,
|
||||||
years,
|
years,
|
||||||
yearMonths,
|
yearMonths,
|
||||||
yearMonthDays,
|
yearMonthDays,
|
||||||
});
|
});
|
||||||
|
|
||||||
onProgress(
|
|
||||||
48,
|
|
||||||
`Targeted rerender plan: singles=${requestedSinglePosts.length}, categories=${targetedPlan.requestedCategorySet.size}, tags=${targetedPlan.requestedTagSet.size}, years=${requestedYearsMap.size}, months=${requestedYearMonthsMap.size}, days=${requestedYearMonthDaysMap.size}, root=${targetedPlan.requestRootRoutes ? 1 : 0}, pages=${requestedPagePosts.length}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
onProgress(50, 'Rendering targeted missing routes...');
|
|
||||||
|
|
||||||
if (targetedPlan.requestRootRoutes) {
|
|
||||||
renderedUrlCount += await generateRootPages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: publishedListPosts,
|
|
||||||
maxPostsPerPage,
|
|
||||||
renderRoute,
|
|
||||||
writePage,
|
|
||||||
onPageGenerated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedPagePosts.length > 0) {
|
|
||||||
renderedUrlCount += await generatePageRoutes({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: requestedPagePosts,
|
|
||||||
renderRoute,
|
|
||||||
writePage,
|
|
||||||
onPageGenerated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetedPlan.requestedCategorySet.size > 0) {
|
|
||||||
renderedUrlCount += await generateCategoryPages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: publishedListPosts,
|
|
||||||
allCategories: targetedPlan.requestedCategorySet,
|
|
||||||
maxPostsPerPage,
|
|
||||||
renderRoute,
|
|
||||||
writePage,
|
|
||||||
onPageGenerated,
|
|
||||||
postsByCategory: generationPostIndex.postsByCategory,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetedPlan.requestedTagSet.size > 0) {
|
|
||||||
renderedUrlCount += await generateTagPages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: publishedListPosts,
|
|
||||||
allTags: targetedPlan.requestedTagSet,
|
|
||||||
maxPostsPerPage,
|
|
||||||
renderRoute,
|
|
||||||
writePage,
|
|
||||||
onPageGenerated,
|
|
||||||
postsByTag: generationPostIndex.postsByTag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedSinglePosts.length > 0) {
|
|
||||||
renderedUrlCount += await generateSinglePostPages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: requestedSinglePosts,
|
|
||||||
renderRoute,
|
|
||||||
writePage,
|
|
||||||
onPageGenerated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedYearsMap.size > 0 || requestedYearMonthsMap.size > 0 || requestedYearMonthDaysMap.size > 0) {
|
|
||||||
renderedUrlCount += await generateDateArchivePages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: publishedListPosts,
|
|
||||||
yearsMap: requestedYearsMap,
|
|
||||||
yearMonthsMap: requestedYearMonthsMap,
|
|
||||||
yearMonthDaysMap: requestedYearMonthDaysMap,
|
|
||||||
maxPostsPerPage,
|
|
||||||
renderRoute,
|
|
||||||
writePage,
|
|
||||||
onPageGenerated,
|
|
||||||
postsByYear: generationPostIndex.postsByYear,
|
|
||||||
postsByYearMonth: generationPostIndex.postsByYearMonth,
|
|
||||||
postsByYearMonthDay: generationPostIndex.postsByYearMonthDay,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Render missing per-language subtree pages ---
|
|
||||||
for (const [lang, langMissingPlan] of missingPathPlan.languagePlans) {
|
for (const [lang, langMissingPlan] of missingPathPlan.languagePlans) {
|
||||||
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
const langPosts = publishedPosts.filter((p) => !p.doNotTranslate);
|
||||||
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
const langListPosts = publishedListPosts.filter((p) => !p.doNotTranslate);
|
||||||
const langPostIndex = buildGenerationPostIndex(langListPosts);
|
|
||||||
const langArchives = buildApplyValidationArchives(langListPosts);
|
const langArchives = buildApplyValidationArchives(langListPosts);
|
||||||
|
|
||||||
const langTargetedPlan = buildTargetedValidationPlan({
|
const langTargetedPlan = buildTargetedValidationPlan({
|
||||||
initialPlan: langMissingPlan,
|
initialPlan: langMissingPlan,
|
||||||
publishedPosts: langPosts,
|
publishedPosts: langPosts,
|
||||||
@@ -1781,138 +1784,386 @@ export class BlogGenerationEngine {
|
|||||||
availableYearMonthDays: langArchives.yearMonthDays.keys(),
|
availableYearMonthDays: langArchives.yearMonthDays.keys(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
|
const resolvedLangPosts = this.resolvePostsForLanguage(langPosts, lang, translationsByPost, mainLanguage);
|
||||||
options: { ...options, language: lang },
|
const resolvedLangListPosts = this.resolvePostsForLanguage(langListPosts, lang, translationsByPost, mainLanguage);
|
||||||
maxPostsPerPage,
|
const resolvedLangPostIndex = buildGenerationPostIndex(resolvedLangListPosts);
|
||||||
publishedPostsForLookup: langPosts,
|
|
||||||
languagePrefix: `/${lang}`,
|
const langTasks = buildApplyValidationWorkerTasks(
|
||||||
engines: {
|
{ ...baseWorkerParams, options: { ...serializedOptions, language: lang } },
|
||||||
postEngine: this.postEngine,
|
{
|
||||||
mediaEngine: this.mediaEngine,
|
targetedPlan: langTargetedPlan,
|
||||||
postMediaEngine: this.postMediaEngine,
|
publishedRoutePosts: resolvedLangPosts,
|
||||||
|
publishedListPosts: resolvedLangListPosts,
|
||||||
|
generationPostIndex: resolvedLangPostIndex,
|
||||||
|
years: langArchives.years,
|
||||||
|
yearMonths: langArchives.yearMonths,
|
||||||
|
yearMonthDays: langArchives.yearMonthDays,
|
||||||
|
languagePrefix: `/${lang}`,
|
||||||
|
mainLanguage,
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
allTasks.push(...langTasks);
|
||||||
|
}
|
||||||
|
|
||||||
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
|
for (const task of allTasks) {
|
||||||
projectId,
|
const sectionTasks = workerTasksBySection.get(task.section) ?? [];
|
||||||
htmlDir,
|
sectionTasks.push(task);
|
||||||
urlPath: `${lang}/${urlPath}`,
|
workerTasksBySection.set(task.section, sectionTasks);
|
||||||
content,
|
}
|
||||||
refreshHashTimestampOnUnchanged: true,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (langTargetedPlan.requestRootRoutes) {
|
// Determine which sections have work
|
||||||
|
const sectionsToRender: BlogGenerationSection[] = useWorkers
|
||||||
|
? Array.from(workerTasksBySection.keys())
|
||||||
|
: this.computeTargetedSectionsToRender(targetedPlan, missingPathPlan, publishedPosts, publishedListPosts);
|
||||||
|
|
||||||
|
onProgress(100, 'Preparation complete');
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletedUrlCount,
|
||||||
|
removedEmptyDirCount,
|
||||||
|
requiresFallbackSectionRender: false,
|
||||||
|
sectionsToRender,
|
||||||
|
workerTasksBySection,
|
||||||
|
targetedRenderData: !useWorkers ? {
|
||||||
|
options,
|
||||||
|
maxPostsPerPage,
|
||||||
|
htmlDir,
|
||||||
|
publishedPosts,
|
||||||
|
publishedListPosts,
|
||||||
|
publishedRoutePosts,
|
||||||
|
generationPostIndex,
|
||||||
|
targetedPlan,
|
||||||
|
missingPathPlan,
|
||||||
|
mainLanguage,
|
||||||
|
additionalLanguages,
|
||||||
|
years,
|
||||||
|
yearMonths,
|
||||||
|
yearMonthDays,
|
||||||
|
} : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeTargetedSectionsToRender(
|
||||||
|
targetedPlan: import('./ValidationApplyPlannerService').TargetedValidationPlan,
|
||||||
|
missingPathPlan: import('./ValidationApplyPlannerService').MissingPathPlan,
|
||||||
|
publishedPosts: PostData[],
|
||||||
|
publishedListPosts: PostData[],
|
||||||
|
): BlogGenerationSection[] {
|
||||||
|
const sections = new Set<BlogGenerationSection>();
|
||||||
|
|
||||||
|
const checkPlan = (plan: import('./ValidationApplyPlannerService').TargetedValidationPlan) => {
|
||||||
|
if (plan.requestRootRoutes || plan.requestedPageSlugs.size > 0) sections.add('core');
|
||||||
|
if (plan.requestedPostIds.size > 0) sections.add('single');
|
||||||
|
if (plan.requestedCategorySet.size > 0) sections.add('category');
|
||||||
|
if (plan.requestedTagSet.size > 0) sections.add('tag');
|
||||||
|
if (plan.requestedYears.size > 0 || plan.requestedYearMonths.size > 0 || plan.requestedYearMonthDays.size > 0) sections.add('date');
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPlan(targetedPlan);
|
||||||
|
|
||||||
|
for (const [, langMissingPlan] of missingPathPlan.languagePlans) {
|
||||||
|
const langPosts = publishedPosts.filter((p) => !p.doNotTranslate);
|
||||||
|
const langListPosts = publishedListPosts.filter((p) => !p.doNotTranslate);
|
||||||
|
const langArchives = buildApplyValidationArchives(langListPosts);
|
||||||
|
const langTargetedPlan = buildTargetedValidationPlan({
|
||||||
|
initialPlan: langMissingPlan,
|
||||||
|
publishedPosts: langPosts,
|
||||||
|
allCategories: langArchives.allCategories,
|
||||||
|
allTags: langArchives.allTags,
|
||||||
|
availableYearMonths: langArchives.yearMonths.keys(),
|
||||||
|
availableYearMonthDays: langArchives.yearMonthDays.keys(),
|
||||||
|
});
|
||||||
|
checkPlan(langTargetedPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyValidationForSection(
|
||||||
|
options: BlogGenerationOptions,
|
||||||
|
preparation: ApplyValidationPreparation,
|
||||||
|
section: BlogGenerationSection,
|
||||||
|
onProgress: (progress: number, message?: string) => void,
|
||||||
|
): Promise<number> {
|
||||||
|
if (preparation.requiresFallbackSectionRender) {
|
||||||
|
const result = await this.generate({
|
||||||
|
...options,
|
||||||
|
maxPostsPerPage: options.maxPostsPerPage,
|
||||||
|
sections: [section],
|
||||||
|
}, (progress, message) => {
|
||||||
|
onProgress(progress, message || `Rendering ${section} routes...`);
|
||||||
|
});
|
||||||
|
return result.pagesGenerated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker-based targeted rendering for this section
|
||||||
|
const sectionTasks = preparation.workerTasksBySection.get(section);
|
||||||
|
if (sectionTasks && sectionTasks.length > 0) {
|
||||||
|
onProgress(0, `Dispatching ${sectionTasks.length} targeted ${section} tasks to worker pool...`);
|
||||||
|
const pool = new GenerationWorkerPool();
|
||||||
|
const result = await pool.runTasks(sectionTasks, (message) => {
|
||||||
|
onProgress(50, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
console.error(`[ApplyValidation/${section}] ${result.errors.length} task(s) failed:`);
|
||||||
|
for (const err of result.errors) {
|
||||||
|
console.error(` [${err.taskId}] ${err.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.hashUpdates.length > 0) {
|
||||||
|
for (const update of result.hashUpdates) {
|
||||||
|
await setGeneratedFileHash(options.projectId, update.relativePath, update.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(100, `${section} rendering complete`);
|
||||||
|
return result.pagesGenerated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main-thread fallback for this section
|
||||||
|
if (preparation.targetedRenderData) {
|
||||||
|
return this.applyValidationSectionOnMainThread(preparation.targetedRenderData, section, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-section main-thread targeted apply validation ────────────────
|
||||||
|
|
||||||
|
private async applyValidationSectionOnMainThread(
|
||||||
|
data: ApplyValidationTargetedRenderData,
|
||||||
|
section: BlogGenerationSection,
|
||||||
|
onProgress: (progress: number, message?: string) => void,
|
||||||
|
): Promise<number> {
|
||||||
|
const {
|
||||||
|
options, maxPostsPerPage, htmlDir,
|
||||||
|
publishedPosts, publishedListPosts, publishedRoutePosts,
|
||||||
|
generationPostIndex, targetedPlan, missingPathPlan,
|
||||||
|
years, yearMonths, yearMonthDays,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
let renderedUrlCount = 0;
|
||||||
|
|
||||||
|
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||||
|
options,
|
||||||
|
maxPostsPerPage,
|
||||||
|
publishedPostsForLookup: publishedRoutePosts,
|
||||||
|
engines: {
|
||||||
|
postEngine: this.postEngine,
|
||||||
|
mediaEngine: this.mediaEngine,
|
||||||
|
postMediaEngine: this.postMediaEngine,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
|
||||||
|
projectId,
|
||||||
|
htmlDir,
|
||||||
|
urlPath,
|
||||||
|
content,
|
||||||
|
refreshHashTimestampOnUnchanged: true,
|
||||||
|
});
|
||||||
|
const onPageGenerated = (_message: string) => {
|
||||||
|
// no-op for applyValidation
|
||||||
|
};
|
||||||
|
|
||||||
|
const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({
|
||||||
|
publishedPosts: publishedRoutePosts,
|
||||||
|
requestedPostIds: targetedPlan.requestedPostIds,
|
||||||
|
requestedPageSlugs: targetedPlan.requestedPageSlugs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { requestedYearsMap, requestedYearMonthsMap, requestedYearMonthDaysMap } = buildRequestedArchiveMaps({
|
||||||
|
requestedYears: targetedPlan.requestedYears,
|
||||||
|
requestedYearMonths: targetedPlan.requestedYearMonths,
|
||||||
|
requestedYearMonthDays: targetedPlan.requestedYearMonthDays,
|
||||||
|
years,
|
||||||
|
yearMonths,
|
||||||
|
yearMonthDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
onProgress(10, `Rendering ${section} section...`);
|
||||||
|
|
||||||
|
// Main language rendering for this section
|
||||||
|
switch (section) {
|
||||||
|
case 'core':
|
||||||
|
if (targetedPlan.requestRootRoutes) {
|
||||||
renderedUrlCount += await generateRootPages({
|
renderedUrlCount += await generateRootPages({
|
||||||
projectId: options.projectId,
|
projectId: options.projectId, posts: publishedListPosts, maxPostsPerPage,
|
||||||
posts: langListPosts,
|
renderRoute, writePage, onPageGenerated,
|
||||||
maxPostsPerPage,
|
|
||||||
renderRoute: langRenderRoute,
|
|
||||||
writePage: langWritePage,
|
|
||||||
onPageGenerated,
|
|
||||||
});
|
});
|
||||||
const langRequestedPagePosts = selectRequestedPosts({
|
}
|
||||||
publishedPosts: langPosts,
|
if (requestedPagePosts.length > 0) {
|
||||||
requestedPostIds: new Set(),
|
renderedUrlCount += await generatePageRoutes({
|
||||||
requestedPageSlugs: langTargetedPlan.requestedPageSlugs,
|
projectId: options.projectId, posts: requestedPagePosts,
|
||||||
}).requestedPagePosts;
|
renderRoute, writePage, onPageGenerated,
|
||||||
if (langRequestedPagePosts.length > 0) {
|
});
|
||||||
renderedUrlCount += await generatePageRoutes({
|
}
|
||||||
projectId: options.projectId,
|
break;
|
||||||
posts: langRequestedPagePosts,
|
|
||||||
renderRoute: langRenderRoute,
|
case 'single':
|
||||||
writePage: langWritePage,
|
if (requestedSinglePosts.length > 0) {
|
||||||
onPageGenerated,
|
renderedUrlCount += await generateSinglePostPages({
|
||||||
|
projectId: options.projectId, posts: requestedSinglePosts,
|
||||||
|
renderRoute, writePage, onPageGenerated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'category':
|
||||||
|
if (targetedPlan.requestedCategorySet.size > 0) {
|
||||||
|
renderedUrlCount += await generateCategoryPages({
|
||||||
|
projectId: options.projectId, posts: publishedListPosts,
|
||||||
|
allCategories: targetedPlan.requestedCategorySet, maxPostsPerPage,
|
||||||
|
renderRoute, writePage, onPageGenerated,
|
||||||
|
postsByCategory: generationPostIndex.postsByCategory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tag':
|
||||||
|
if (targetedPlan.requestedTagSet.size > 0) {
|
||||||
|
renderedUrlCount += await generateTagPages({
|
||||||
|
projectId: options.projectId, posts: publishedListPosts,
|
||||||
|
allTags: targetedPlan.requestedTagSet, maxPostsPerPage,
|
||||||
|
renderRoute, writePage, onPageGenerated,
|
||||||
|
postsByTag: generationPostIndex.postsByTag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
if (requestedYearsMap.size > 0 || requestedYearMonthsMap.size > 0 || requestedYearMonthDaysMap.size > 0) {
|
||||||
|
renderedUrlCount += await generateDateArchivePages({
|
||||||
|
projectId: options.projectId, posts: publishedListPosts,
|
||||||
|
yearsMap: requestedYearsMap, yearMonthsMap: requestedYearMonthsMap,
|
||||||
|
yearMonthDaysMap: requestedYearMonthDaysMap, maxPostsPerPage,
|
||||||
|
renderRoute, writePage, onPageGenerated,
|
||||||
|
postsByYear: generationPostIndex.postsByYear,
|
||||||
|
postsByYearMonth: generationPostIndex.postsByYearMonth,
|
||||||
|
postsByYearMonthDay: generationPostIndex.postsByYearMonthDay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language subtrees for this section
|
||||||
|
for (const [lang, langMissingPlan] of missingPathPlan.languagePlans) {
|
||||||
|
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||||
|
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||||
|
const langPostIndex = buildGenerationPostIndex(langListPosts);
|
||||||
|
const langArchives = buildApplyValidationArchives(langListPosts);
|
||||||
|
|
||||||
|
const langTargetedPlan = buildTargetedValidationPlan({
|
||||||
|
initialPlan: langMissingPlan,
|
||||||
|
publishedPosts: langPosts,
|
||||||
|
allCategories: langArchives.allCategories,
|
||||||
|
allTags: langArchives.allTags,
|
||||||
|
availableYearMonths: langArchives.yearMonths.keys(),
|
||||||
|
availableYearMonthDays: langArchives.yearMonthDays.keys(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||||
|
options: { ...options, language: lang },
|
||||||
|
maxPostsPerPage,
|
||||||
|
publishedPostsForLookup: langPosts,
|
||||||
|
languagePrefix: `/${lang}`,
|
||||||
|
engines: {
|
||||||
|
postEngine: this.postEngine,
|
||||||
|
mediaEngine: this.mediaEngine,
|
||||||
|
postMediaEngine: this.postMediaEngine,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
|
||||||
|
projectId,
|
||||||
|
htmlDir,
|
||||||
|
urlPath: `${lang}/${urlPath}`,
|
||||||
|
content,
|
||||||
|
refreshHashTimestampOnUnchanged: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (section) {
|
||||||
|
case 'core':
|
||||||
|
if (langTargetedPlan.requestRootRoutes) {
|
||||||
|
renderedUrlCount += await generateRootPages({
|
||||||
|
projectId: options.projectId, posts: langListPosts, maxPostsPerPage,
|
||||||
|
renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated,
|
||||||
|
});
|
||||||
|
const langPagePosts = selectRequestedPosts({
|
||||||
|
publishedPosts: langPosts, requestedPostIds: new Set(), requestedPageSlugs: langTargetedPlan.requestedPageSlugs,
|
||||||
|
}).requestedPagePosts;
|
||||||
|
if (langPagePosts.length > 0) {
|
||||||
|
renderedUrlCount += await generatePageRoutes({
|
||||||
|
projectId: options.projectId, posts: langPagePosts,
|
||||||
|
renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'category':
|
||||||
|
if (langTargetedPlan.requestedCategorySet.size > 0) {
|
||||||
|
renderedUrlCount += await generateCategoryPages({
|
||||||
|
projectId: options.projectId, posts: langListPosts,
|
||||||
|
allCategories: langTargetedPlan.requestedCategorySet, maxPostsPerPage,
|
||||||
|
renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated,
|
||||||
|
postsByCategory: langPostIndex.postsByCategory,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tag':
|
||||||
|
if (langTargetedPlan.requestedTagSet.size > 0) {
|
||||||
|
renderedUrlCount += await generateTagPages({
|
||||||
|
projectId: options.projectId, posts: langListPosts,
|
||||||
|
allTags: langTargetedPlan.requestedTagSet, maxPostsPerPage,
|
||||||
|
renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated,
|
||||||
|
postsByTag: langPostIndex.postsByTag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'single': {
|
||||||
|
const langSinglePosts = selectRequestedPosts({
|
||||||
|
publishedPosts: langPosts, requestedPostIds: langTargetedPlan.requestedPostIds, requestedPageSlugs: new Set(),
|
||||||
|
}).requestedSinglePosts;
|
||||||
|
if (langSinglePosts.length > 0) {
|
||||||
|
renderedUrlCount += await generateSinglePostPages({
|
||||||
|
projectId: options.projectId, posts: langSinglePosts,
|
||||||
|
renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (langTargetedPlan.requestedCategorySet.size > 0) {
|
case 'date': {
|
||||||
renderedUrlCount += await generateCategoryPages({
|
const langArchiveMaps = buildRequestedArchiveMaps({
|
||||||
projectId: options.projectId,
|
requestedYears: langTargetedPlan.requestedYears,
|
||||||
posts: langListPosts,
|
requestedYearMonths: langTargetedPlan.requestedYearMonths,
|
||||||
allCategories: langTargetedPlan.requestedCategorySet,
|
requestedYearMonthDays: langTargetedPlan.requestedYearMonthDays,
|
||||||
maxPostsPerPage,
|
years: langArchives.years, yearMonths: langArchives.yearMonths, yearMonthDays: langArchives.yearMonthDays,
|
||||||
renderRoute: langRenderRoute,
|
|
||||||
writePage: langWritePage,
|
|
||||||
onPageGenerated,
|
|
||||||
postsByCategory: langPostIndex.postsByCategory,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (langTargetedPlan.requestedTagSet.size > 0) {
|
|
||||||
renderedUrlCount += await generateTagPages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: langListPosts,
|
|
||||||
allTags: langTargetedPlan.requestedTagSet,
|
|
||||||
maxPostsPerPage,
|
|
||||||
renderRoute: langRenderRoute,
|
|
||||||
writePage: langWritePage,
|
|
||||||
onPageGenerated,
|
|
||||||
postsByTag: langPostIndex.postsByTag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const langRequestedSinglePosts = selectRequestedPosts({
|
|
||||||
publishedPosts: langPosts,
|
|
||||||
requestedPostIds: langTargetedPlan.requestedPostIds,
|
|
||||||
requestedPageSlugs: new Set(),
|
|
||||||
}).requestedSinglePosts;
|
|
||||||
|
|
||||||
if (langRequestedSinglePosts.length > 0) {
|
|
||||||
renderedUrlCount += await generateSinglePostPages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: langRequestedSinglePosts,
|
|
||||||
renderRoute: langRenderRoute,
|
|
||||||
writePage: langWritePage,
|
|
||||||
onPageGenerated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const langRequestedArchives = buildRequestedArchiveMaps({
|
|
||||||
requestedYears: langTargetedPlan.requestedYears,
|
|
||||||
requestedYearMonths: langTargetedPlan.requestedYearMonths,
|
|
||||||
requestedYearMonthDays: langTargetedPlan.requestedYearMonthDays,
|
|
||||||
years: langArchives.years,
|
|
||||||
yearMonths: langArchives.yearMonths,
|
|
||||||
yearMonthDays: langArchives.yearMonthDays,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (langRequestedArchives.requestedYearsMap.size > 0 || langRequestedArchives.requestedYearMonthsMap.size > 0 || langRequestedArchives.requestedYearMonthDaysMap.size > 0) {
|
|
||||||
renderedUrlCount += await generateDateArchivePages({
|
|
||||||
projectId: options.projectId,
|
|
||||||
posts: langListPosts,
|
|
||||||
yearsMap: langRequestedArchives.requestedYearsMap,
|
|
||||||
yearMonthsMap: langRequestedArchives.requestedYearMonthsMap,
|
|
||||||
yearMonthDaysMap: langRequestedArchives.requestedYearMonthDaysMap,
|
|
||||||
maxPostsPerPage,
|
|
||||||
renderRoute: langRenderRoute,
|
|
||||||
writePage: langWritePage,
|
|
||||||
onPageGenerated,
|
|
||||||
postsByYear: langPostIndex.postsByYear,
|
|
||||||
postsByYearMonth: langPostIndex.postsByYearMonth,
|
|
||||||
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
|
|
||||||
});
|
});
|
||||||
|
if (langArchiveMaps.requestedYearsMap.size > 0 || langArchiveMaps.requestedYearMonthsMap.size > 0 || langArchiveMaps.requestedYearMonthDaysMap.size > 0) {
|
||||||
|
renderedUrlCount += await generateDateArchivePages({
|
||||||
|
projectId: options.projectId, posts: langListPosts,
|
||||||
|
yearsMap: langArchiveMaps.requestedYearsMap,
|
||||||
|
yearMonthsMap: langArchiveMaps.requestedYearMonthsMap,
|
||||||
|
yearMonthDaysMap: langArchiveMaps.requestedYearMonthDaysMap,
|
||||||
|
maxPostsPerPage, renderRoute: langRenderRoute, writePage: langWritePage, onPageGenerated,
|
||||||
|
postsByYear: langPostIndex.postsByYear, postsByYearMonth: langPostIndex.postsByYearMonth,
|
||||||
|
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (renderedUrlCount > 0 || deletedUrlCount > 0) {
|
onProgress(100, `${section} rendering complete`);
|
||||||
onProgress(90, 'Regenerating calendar data...');
|
return renderedUrlCount;
|
||||||
await this.regenerateCalendar(options, (progress, message) => {
|
|
||||||
const mappedProgress = 90 + Math.floor((progress / 100) * 9);
|
|
||||||
onProgress(Math.min(99, mappedProgress), message || 'Regenerating calendar data...');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress(100, `Apply complete (${deletedUrlCount} deleted, ${renderedUrlCount} rendered)`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
renderedUrlCount,
|
|
||||||
deletedUrlCount,
|
|
||||||
removedEmptyDirCount,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -282,31 +282,18 @@ export function buildTargetedValidationPlan(params: BuildTargetedValidationPlanP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const year of Array.from(requestedYears.values())) {
|
// Upward cascade only: day → month → year
|
||||||
for (const ym of availableYearMonths) {
|
|
||||||
if (ym.startsWith(`${year}/`)) {
|
|
||||||
requestedYearMonths.add(ym);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const ym of Array.from(requestedYearMonths.values())) {
|
|
||||||
for (const ymd of availableYearMonthDays) {
|
|
||||||
if (ymd.startsWith(`${ym}/`)) {
|
|
||||||
requestedYearMonthDays.add(ymd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [yearStr] = ym.split('/');
|
|
||||||
requestedYears.add(Number(yearStr));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const ymd of Array.from(requestedYearMonthDays.values())) {
|
for (const ymd of Array.from(requestedYearMonthDays.values())) {
|
||||||
const [yearStr, monthStr] = ymd.split('/');
|
const [yearStr, monthStr] = ymd.split('/');
|
||||||
requestedYears.add(Number(yearStr));
|
requestedYears.add(Number(yearStr));
|
||||||
requestedYearMonths.add(`${yearStr}/${monthStr}`);
|
requestedYearMonths.add(`${yearStr}/${monthStr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const ym of Array.from(requestedYearMonths.values())) {
|
||||||
|
const [yearStr] = ym.split('/');
|
||||||
|
requestedYears.add(Number(yearStr));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestedPostIds,
|
requestedPostIds,
|
||||||
requestedCategorySet: new Set(
|
requestedCategorySet: new Set(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type BlogGenerationSection,
|
type BlogGenerationSection,
|
||||||
type BlogGenerationOptions,
|
type BlogGenerationOptions,
|
||||||
type SiteValidationReport,
|
type SiteValidationReport,
|
||||||
|
type ApplyValidationPreparation,
|
||||||
} from '../engine/BlogGenerationEngine';
|
} from '../engine/BlogGenerationEngine';
|
||||||
import { resolvePageTitle } from '../engine/PageRenderer';
|
import { resolvePageTitle } from '../engine/PageRenderer';
|
||||||
import type { EngineBundle } from '../engine/EngineBundle';
|
import type { EngineBundle } from '../engine/EngineBundle';
|
||||||
@@ -328,14 +329,71 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
|
|||||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
const taskTimestamp = Date.now();
|
const taskTimestamp = Date.now();
|
||||||
return bundle.taskManager.runTask({
|
const taskGroupId = `site-validate-apply-${taskTimestamp}`;
|
||||||
id: `site-validate-apply-${taskTimestamp}`,
|
const taskGroupName = 'Apply Site Validation';
|
||||||
name: 'Apply Site Validation',
|
|
||||||
|
const sectionDisplayNames: Record<BlogGenerationSection, string> = {
|
||||||
|
core: 'Render Site Core',
|
||||||
|
single: 'Render Single Posts',
|
||||||
|
category: 'Render Category Archives',
|
||||||
|
tag: 'Render Tag Archives',
|
||||||
|
date: 'Render Date Archives',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 1: Prepare — plan & delete extra URLs
|
||||||
|
const preparation = await bundle.taskManager.runTask({
|
||||||
|
id: `site-validate-prepare-${taskTimestamp}`,
|
||||||
|
name: 'Prepare Validation Apply',
|
||||||
|
groupId: taskGroupId,
|
||||||
|
groupName: taskGroupName,
|
||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
return blogGenerationEngine.applyValidation(baseOptions, report, (progress, message) => {
|
return blogGenerationEngine.prepareApplyValidation(baseOptions, report, (progress, message) => {
|
||||||
onProgress(progress, message || 'Applying site validation...');
|
onProgress(progress, message || 'Preparing validation apply...');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phase 2: Render sections in parallel (grouped tasks)
|
||||||
|
let renderedUrlCount = 0;
|
||||||
|
|
||||||
|
if (preparation.sectionsToRender.length > 0) {
|
||||||
|
const sectionResults = await Promise.all(
|
||||||
|
preparation.sectionsToRender.map((section) =>
|
||||||
|
bundle.taskManager.runTask({
|
||||||
|
id: `site-validate-render-${section}-${taskTimestamp}`,
|
||||||
|
name: sectionDisplayNames[section],
|
||||||
|
groupId: taskGroupId,
|
||||||
|
groupName: taskGroupName,
|
||||||
|
execute: async (onProgress) => {
|
||||||
|
return blogGenerationEngine.applyValidationForSection(baseOptions, preparation, section, (progress, message) => {
|
||||||
|
onProgress(progress, message || `Rendering ${section} routes...`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderedUrlCount = sectionResults.reduce((sum, count) => sum + count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Regenerate calendar (if anything changed)
|
||||||
|
if (renderedUrlCount > 0 || preparation.deletedUrlCount > 0) {
|
||||||
|
await bundle.taskManager.runTask({
|
||||||
|
id: `site-validate-calendar-${taskTimestamp}`,
|
||||||
|
name: 'Regenerate Calendar',
|
||||||
|
groupId: taskGroupId,
|
||||||
|
groupName: taskGroupName,
|
||||||
|
execute: async (onProgress) => {
|
||||||
|
return blogGenerationEngine.regenerateCalendar(baseOptions, (progress, message) => {
|
||||||
|
onProgress(progress, message || 'Regenerating calendar...');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderedUrlCount,
|
||||||
|
deletedUrlCount: preparation.deletedUrlCount,
|
||||||
|
removedEmptyDirCount: preparation.removedEmptyDirCount,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,6 +449,8 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
|
|||||||
content: transformResult.post.content,
|
content: transformResult.post.content,
|
||||||
tags: transformResult.post.tags,
|
tags: transformResult.post.tags,
|
||||||
categories: transformResult.post.categories,
|
categories: transformResult.post.categories,
|
||||||
|
language: (metadata as { mainLanguage?: string } | null)?.mainLanguage || undefined,
|
||||||
|
author: (metadata as { defaultAuthor?: string } | null)?.defaultAuthor || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blogmarkCreatedPayload = {
|
const blogmarkCreatedPayload = {
|
||||||
|
|||||||
@@ -235,6 +235,17 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const canonicalLanguage = postLanguage || post?.language || projectLanguage;
|
const canonicalLanguage = postLanguage || post?.language || projectLanguage;
|
||||||
const fieldIdPrefix = `post-editor-${postId}`;
|
const fieldIdPrefix = `post-editor-${postId}`;
|
||||||
|
|
||||||
|
// Keep activeEditingLanguage in sync when canonicalLanguage changes
|
||||||
|
// (e.g. due to async projectLanguage loading after post init)
|
||||||
|
const prevCanonicalLanguageRef = useRef(canonicalLanguage);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevCanonicalLanguageRef.current;
|
||||||
|
prevCanonicalLanguageRef.current = canonicalLanguage;
|
||||||
|
if (prev !== canonicalLanguage && activeEditingLanguage === prev) {
|
||||||
|
setActiveEditingLanguage(canonicalLanguage);
|
||||||
|
}
|
||||||
|
}, [canonicalLanguage, activeEditingLanguage]);
|
||||||
|
|
||||||
const loadTranslations = useCallback(async () => {
|
const loadTranslations = useCallback(async () => {
|
||||||
const result = await window.electronAPI?.posts.getTranslations?.(postId);
|
const result = await window.electronAPI?.posts.getTranslations?.(postId);
|
||||||
const items = result || [];
|
const items = result || [];
|
||||||
|
|||||||
352
tests/engine/ApplyValidationWorkerService.test.ts
Normal file
352
tests/engine/ApplyValidationWorkerService.test.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildApplyValidationWorkerTasks,
|
||||||
|
type ApplyValidationWorkerParams,
|
||||||
|
type ApplyValidationLanguageParams,
|
||||||
|
} from '../../src/main/engine/ApplyValidationWorkerService';
|
||||||
|
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||||
|
import { buildGenerationPostIndex } from '../../src/main/engine/GenerationPostIndexService';
|
||||||
|
import type { TargetedValidationPlan } from '../../src/main/engine/ValidationApplyPlannerService';
|
||||||
|
import type { SerializedBlogGenerationOptions } from '../../src/main/engine/GenerationWorkerData';
|
||||||
|
|
||||||
|
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||||
|
const createdAt = overrides.createdAt ?? new Date('2025-01-15T10:00:00Z');
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'post-1',
|
||||||
|
projectId: 'test',
|
||||||
|
title: overrides.title ?? 'Test Post',
|
||||||
|
slug: overrides.slug ?? 'test-post',
|
||||||
|
content: overrides.content ?? '# Test',
|
||||||
|
status: 'published',
|
||||||
|
createdAt,
|
||||||
|
updatedAt: overrides.updatedAt ?? createdAt,
|
||||||
|
publishedAt: createdAt,
|
||||||
|
tags: overrides.tags ?? [],
|
||||||
|
categories: overrides.categories ?? [],
|
||||||
|
availableLanguages: overrides.availableLanguages ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBaseParams(overrides?: Partial<ApplyValidationWorkerParams>): ApplyValidationWorkerParams {
|
||||||
|
const options: SerializedBlogGenerationOptions = {
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: '/data',
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
maxPostsPerPage: 50,
|
||||||
|
htmlDir: '/data/html',
|
||||||
|
mediaItems: [],
|
||||||
|
backlinksRecord: {},
|
||||||
|
hashMapEntries: [],
|
||||||
|
postFilePathEntries: [],
|
||||||
|
postMediaLinksEntries: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmptyTargetedPlan(overrides?: Partial<TargetedValidationPlan>): TargetedValidationPlan {
|
||||||
|
return {
|
||||||
|
requestedPostIds: new Set(),
|
||||||
|
requestedCategorySet: new Set(),
|
||||||
|
requestedTagSet: new Set(),
|
||||||
|
requestedYears: new Set(),
|
||||||
|
requestedYearMonths: new Set(),
|
||||||
|
requestedYearMonthDays: new Set(),
|
||||||
|
requestedPageSlugs: new Set(),
|
||||||
|
requestRootRoutes: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildApplyValidationWorkerTasks', () => {
|
||||||
|
it('returns empty tasks when plan has no requests', () => {
|
||||||
|
const posts = [makePost()];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan(),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a single-post task containing only requested post IDs', () => {
|
||||||
|
const post1 = makePost({ id: 'p1', slug: 'post-one', tags: ['alpha'], categories: ['news'] });
|
||||||
|
const post2 = makePost({ id: 'p2', slug: 'post-two', tags: ['beta'], categories: ['other'] });
|
||||||
|
const posts = [post1, post2];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestedPostIds: new Set(['p1']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const singleTasks = tasks.filter((t) => t.section === 'single');
|
||||||
|
expect(singleTasks).toHaveLength(1);
|
||||||
|
expect(singleTasks[0].posts).toHaveLength(1);
|
||||||
|
expect(singleTasks[0].posts[0].id).toBe('p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a category task containing only requested categories', () => {
|
||||||
|
const post1 = makePost({ id: 'p1', categories: ['news'], tags: [] });
|
||||||
|
const post2 = makePost({ id: 'p2', categories: ['other'], tags: [] });
|
||||||
|
const posts = [post1, post2];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestedCategorySet: new Set(['news']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const catTasks = tasks.filter((t) => t.section === 'category');
|
||||||
|
expect(catTasks).toHaveLength(1);
|
||||||
|
expect(catTasks[0].allCategories).toEqual(['news']);
|
||||||
|
expect(catTasks[0].postsByCategoryEntries).toHaveLength(1);
|
||||||
|
expect(catTasks[0].postsByCategoryEntries![0][0]).toBe('news');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a tag task containing only requested tags', () => {
|
||||||
|
const post1 = makePost({ id: 'p1', tags: ['alpha'] });
|
||||||
|
const post2 = makePost({ id: 'p2', tags: ['beta'] });
|
||||||
|
const posts = [post1, post2];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestedTagSet: new Set(['alpha']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagTasks = tasks.filter((t) => t.section === 'tag');
|
||||||
|
expect(tagTasks).toHaveLength(1);
|
||||||
|
expect(tagTasks[0].allTags).toEqual(['alpha']);
|
||||||
|
expect(tagTasks[0].postsByTagEntries).toHaveLength(1);
|
||||||
|
expect(tagTasks[0].postsByTagEntries![0][0]).toBe('alpha');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a date task containing only requested year/month/day archives', () => {
|
||||||
|
const post1 = makePost({ id: 'p1', createdAt: new Date('2025-01-15T10:00:00Z') });
|
||||||
|
const post2 = makePost({ id: 'p2', createdAt: new Date('2024-06-20T10:00:00Z') });
|
||||||
|
const posts = [post1, post2];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const years = new Map<number, Date>([[2025, new Date()], [2024, new Date()]]);
|
||||||
|
const yearMonths = new Map<string, Date>([['2025/01', new Date()], ['2024/06', new Date()]]);
|
||||||
|
const yearMonthDays = new Map<string, Date>([['2025/01/15', new Date()], ['2024/06/20', new Date()]]);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestedYears: new Set([2025]),
|
||||||
|
requestedYearMonths: new Set(['2025/01']),
|
||||||
|
requestedYearMonthDays: new Set(['2025/01/15']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years,
|
||||||
|
yearMonths,
|
||||||
|
yearMonthDays,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateTasks = tasks.filter((t) => t.section === 'date');
|
||||||
|
expect(dateTasks).toHaveLength(1);
|
||||||
|
expect(dateTasks[0].yearsEntries).toHaveLength(1);
|
||||||
|
expect(dateTasks[0].yearsEntries![0][0]).toBe(2025);
|
||||||
|
expect(dateTasks[0].yearMonthsEntries).toHaveLength(1);
|
||||||
|
expect(dateTasks[0].yearMonthsEntries![0][0]).toBe('2025/01');
|
||||||
|
expect(dateTasks[0].yearMonthDaysEntries).toHaveLength(1);
|
||||||
|
expect(dateTasks[0].yearMonthDaysEntries![0][0]).toBe('2025/01/15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a core task when requestRootRoutes is true', () => {
|
||||||
|
const posts = [makePost()];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestRootRoutes: true,
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const coreTasks = tasks.filter((t) => t.section === 'core');
|
||||||
|
expect(coreTasks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create core task when requestRootRoutes is false', () => {
|
||||||
|
const posts = [makePost()];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestRootRoutes: false,
|
||||||
|
requestedPostIds: new Set(['post-1']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tasks.filter((t) => t.section === 'core')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates tasks across multiple sections for a comprehensive plan', () => {
|
||||||
|
const post1 = makePost({
|
||||||
|
id: 'p1', slug: 'target-post',
|
||||||
|
categories: ['news'], tags: ['alpha'],
|
||||||
|
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||||
|
});
|
||||||
|
const post2 = makePost({
|
||||||
|
id: 'p2', slug: 'other-post',
|
||||||
|
categories: ['other'], tags: ['beta'],
|
||||||
|
createdAt: new Date('2024-06-20T10:00:00Z'),
|
||||||
|
});
|
||||||
|
const posts = [post1, post2];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const years = new Map<number, Date>([[2025, new Date()]]);
|
||||||
|
const yearMonths = new Map<string, Date>([['2025/01', new Date()]]);
|
||||||
|
const yearMonthDays = new Map<string, Date>([['2025/01/15', new Date()]]);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestRootRoutes: true,
|
||||||
|
requestedPostIds: new Set(['p1']),
|
||||||
|
requestedCategorySet: new Set(['news']),
|
||||||
|
requestedTagSet: new Set(['alpha']),
|
||||||
|
requestedYears: new Set([2025]),
|
||||||
|
requestedYearMonths: new Set(['2025/01']),
|
||||||
|
requestedYearMonthDays: new Set(['2025/01/15']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years,
|
||||||
|
yearMonths,
|
||||||
|
yearMonthDays,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tasks.filter((t) => t.section === 'core')).toHaveLength(1);
|
||||||
|
expect(tasks.filter((t) => t.section === 'single')).toHaveLength(1);
|
||||||
|
expect(tasks.filter((t) => t.section === 'category')).toHaveLength(1);
|
||||||
|
expect(tasks.filter((t) => t.section === 'tag')).toHaveLength(1);
|
||||||
|
expect(tasks.filter((t) => t.section === 'date')).toHaveLength(1);
|
||||||
|
expect(tasks).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets language prefix on tasks for language subtree rendering', () => {
|
||||||
|
const posts = [makePost({ id: 'p1', tags: ['alpha'] })];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestedTagSet: new Set(['alpha']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
languagePrefix: '/fr',
|
||||||
|
mainLanguage: 'en',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
expect(tasks[0].languagePrefix).toBe('/fr');
|
||||||
|
expect(tasks[0].mainLanguage).toBe('en');
|
||||||
|
expect(tasks[0].options.language).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses all route posts as lookupPosts regardless of targeted plan', () => {
|
||||||
|
const post1 = makePost({ id: 'p1', slug: 'target', tags: ['alpha'] });
|
||||||
|
const post2 = makePost({ id: 'p2', slug: 'other', tags: ['beta'] });
|
||||||
|
const posts = [post1, post2];
|
||||||
|
const postIndex = buildGenerationPostIndex(posts);
|
||||||
|
|
||||||
|
const tasks = buildApplyValidationWorkerTasks(
|
||||||
|
makeBaseParams(),
|
||||||
|
{
|
||||||
|
targetedPlan: makeEmptyTargetedPlan({
|
||||||
|
requestedPostIds: new Set(['p1']),
|
||||||
|
}),
|
||||||
|
publishedRoutePosts: posts,
|
||||||
|
publishedListPosts: posts,
|
||||||
|
generationPostIndex: postIndex,
|
||||||
|
years: new Map(),
|
||||||
|
yearMonths: new Map(),
|
||||||
|
yearMonthDays: new Map(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Single task has only 1 post to render...
|
||||||
|
expect(tasks[0].posts).toHaveLength(1);
|
||||||
|
// ...but lookupPosts contains ALL posts for slug resolution
|
||||||
|
expect(tasks[0].lookupPosts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1496,7 +1496,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
generateSpy.mockRestore();
|
generateSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies validation for a missing month by generating that month and its day archives only', async () => {
|
it('applies validation for a missing month by generating that month and parent year only', async () => {
|
||||||
const posts = [
|
const posts = [
|
||||||
makePost({ id: '1', slug: 'jan-post', createdAt: new Date('2025-01-15T10:00:00Z') }),
|
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') }),
|
makePost({ id: '2', slug: 'feb-post', createdAt: new Date('2025-02-20T10:00:00Z') }),
|
||||||
@@ -1520,13 +1520,17 @@ describe('BlogGenerationEngine', () => {
|
|||||||
existingHtmlUrlCount: 0,
|
existingHtmlUrlCount: 0,
|
||||||
}, vi.fn());
|
}, vi.fn());
|
||||||
|
|
||||||
|
// The requested month and its parent year are rendered
|
||||||
|
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', 'index.html'))).toBe(true);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
|
// Child day archives are NOT cascaded into — only upward cascade
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(false);
|
||||||
|
// Unrelated months are not rendered
|
||||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', 'index.html'))).toBe(false);
|
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);
|
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 () => {
|
it('applies validation for a missing year by generating only that year archive', async () => {
|
||||||
const posts = [
|
const posts = [
|
||||||
makePost({ id: '1', slug: 'year-2025', createdAt: new Date('2025-01-15T10:00:00Z') }),
|
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') }),
|
makePost({ id: '2', slug: 'year-2024', createdAt: new Date('2024-02-20T10:00:00Z') }),
|
||||||
@@ -1550,9 +1554,11 @@ describe('BlogGenerationEngine', () => {
|
|||||||
existingHtmlUrlCount: 0,
|
existingHtmlUrlCount: 0,
|
||||||
}, vi.fn());
|
}, vi.fn());
|
||||||
|
|
||||||
|
// Only the requested year archive is rendered — no downward cascade
|
||||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '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', 'index.html'))).toBe(false);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(false);
|
||||||
|
// Other years are not rendered
|
||||||
expect(await fileExists(path.join(tempDir, 'html', '2024', 'index.html'))).toBe(false);
|
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', 'index.html'))).toBe(false);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', '2024', '02', '20', 'index.html'))).toBe(false);
|
expect(await fileExists(path.join(tempDir, 'html', '2024', '02', '20', 'index.html'))).toBe(false);
|
||||||
|
|||||||
@@ -86,9 +86,11 @@ describe('ValidationApplyPlannerService', () => {
|
|||||||
expect(targeted.requestedTagSet.has('tag-1')).toBe(true);
|
expect(targeted.requestedTagSet.has('tag-1')).toBe(true);
|
||||||
expect(targeted.requestedYears.has(2025)).toBe(true);
|
expect(targeted.requestedYears.has(2025)).toBe(true);
|
||||||
expect(targeted.requestedYearMonths.has('2025/01')).toBe(true);
|
expect(targeted.requestedYearMonths.has('2025/01')).toBe(true);
|
||||||
expect(targeted.requestedYearMonths.has('2025/02')).toBe(true);
|
// 2025/02 should NOT be included — only directly affected months are rerendered
|
||||||
|
expect(targeted.requestedYearMonths.has('2025/02')).toBe(false);
|
||||||
expect(targeted.requestedYearMonthDays.has('2025/01/15')).toBe(true);
|
expect(targeted.requestedYearMonthDays.has('2025/01/15')).toBe(true);
|
||||||
expect(targeted.requestedYearMonthDays.has('2025/02/20')).toBe(true);
|
// 2025/02/20 should NOT be included — only directly affected days are rerendered
|
||||||
|
expect(targeted.requestedYearMonthDays.has('2025/02/20')).toBe(false);
|
||||||
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
|
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
|
||||||
expect(targeted.requestRootRoutes).toBe(true);
|
expect(targeted.requestRootRoutes).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3236,7 +3236,7 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('blog:applyValidation', () => {
|
describe('blog:applyValidation', () => {
|
||||||
it('should run apply via taskManager.runTask', async () => {
|
it('should run grouped tasks via taskManager.runTask', async () => {
|
||||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||||
@@ -3266,9 +3266,12 @@ describe('IPC Handlers', () => {
|
|||||||
existingHtmlUrlCount: 1,
|
existingHtmlUrlCount: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Should run preparation as a grouped task
|
||||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'Apply Site Validation',
|
name: 'Prepare Validation Apply',
|
||||||
|
groupId: expect.stringContaining('site-validate-apply-'),
|
||||||
|
groupName: 'Apply Site Validation',
|
||||||
execute: expect.any(Function),
|
execute: expect.any(Function),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
293
tests/renderer/components/EditorCanonicalLanguageFocus.test.tsx
Normal file
293
tests/renderer/components/EditorCanonicalLanguageFocus.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
|
||||||
|
let monacoOnChange: ((value: string | undefined) => void) | null = null;
|
||||||
|
|
||||||
|
vi.mock('@monaco-editor/react', () => ({
|
||||||
|
default: (props: { value?: string; defaultValue?: string; onChange?: (value: string | undefined) => void; key?: string | number }) => {
|
||||||
|
monacoOnChange = props.onChange ?? null;
|
||||||
|
return <div data-testid="monaco-editor" data-default-value={props.defaultValue} />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/core', () => {
|
||||||
|
const makeChain = () => {
|
||||||
|
const chain = {
|
||||||
|
config: (callback: (ctx: { set: () => void; get: () => { markdownUpdated: () => void } }) => void) => {
|
||||||
|
callback({ set: () => {}, get: () => ({ markdownUpdated: () => {} }) });
|
||||||
|
return chain;
|
||||||
|
},
|
||||||
|
use: () => chain,
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Editor: { make: makeChain },
|
||||||
|
defaultValueCtx: Symbol('defaultValueCtx'),
|
||||||
|
editorViewCtx: Symbol('editorViewCtx'),
|
||||||
|
rootCtx: Symbol('rootCtx'),
|
||||||
|
remarkStringifyOptionsCtx: Symbol('remarkStringifyOptionsCtx'),
|
||||||
|
remarkPluginsCtx: Symbol('remarkPluginsCtx'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/preset/commonmark', () => ({
|
||||||
|
commonmark: {},
|
||||||
|
toggleStrongCommand: { key: 'toggleStrong' },
|
||||||
|
toggleEmphasisCommand: { key: 'toggleEmphasis' },
|
||||||
|
wrapInBlockquoteCommand: { key: 'wrapInBlockquote' },
|
||||||
|
wrapInBulletListCommand: { key: 'wrapInBulletList' },
|
||||||
|
wrapInOrderedListCommand: { key: 'wrapInOrderedList' },
|
||||||
|
insertHrCommand: { key: 'insertHr' },
|
||||||
|
toggleInlineCodeCommand: { key: 'toggleInlineCode' },
|
||||||
|
insertImageCommand: { key: 'insertImage' },
|
||||||
|
toggleLinkCommand: { key: 'toggleLink' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/preset/gfm', () => ({
|
||||||
|
gfm: {},
|
||||||
|
toggleStrikethroughCommand: { key: 'toggleStrike' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/plugin/history', () => ({
|
||||||
|
history: {},
|
||||||
|
undoCommand: { key: 'undo' },
|
||||||
|
redoCommand: { key: 'redo' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/plugin/listener', () => ({
|
||||||
|
listener: {},
|
||||||
|
listenerCtx: Symbol('listenerCtx'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/plugin/clipboard', () => ({ clipboard: {} }));
|
||||||
|
vi.mock('@milkdown/kit/plugin/trailing', () => ({ trailing: {} }));
|
||||||
|
vi.mock('@milkdown/kit/plugin/indent', () => ({ indent: {} }));
|
||||||
|
vi.mock('@milkdown/kit/plugin/cursor', () => ({ cursor: {} }));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/utils', () => ({
|
||||||
|
$node: () => ({}),
|
||||||
|
$inputRule: () => ({}),
|
||||||
|
$remark: () => ({}),
|
||||||
|
$prose: () => ({}),
|
||||||
|
replaceAll: () => () => {},
|
||||||
|
callCommand: () => () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/react', () => ({
|
||||||
|
Milkdown: () => <div data-testid="milkdown" />,
|
||||||
|
MilkdownProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useInstance: () => [false, () => ({ action: (action: unknown) => {
|
||||||
|
if (typeof action === 'function') {
|
||||||
|
action({ get: () => ({}) });
|
||||||
|
}
|
||||||
|
} })] as const,
|
||||||
|
useEditor: (factory: (root: Node) => unknown) => {
|
||||||
|
factory(document.createElement('div'));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/renderer/components/Lightbox', () => ({
|
||||||
|
Lightbox: () => null,
|
||||||
|
useMarkdownImages: () => [],
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/ConfirmDeleteModal', () => ({ ConfirmDeleteModal: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/SettingsView', () => ({ SettingsView: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/TagsView', () => ({ TagsView: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/TagInput', () => ({ TagInput: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/ChatPanel', () => ({ ChatPanel: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/ImportAnalysisView', () => ({ ImportAnalysisView: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataDiffPanel: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null }));
|
||||||
|
vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({
|
||||||
|
AISuggestionsModal: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/renderer/components/Toast', () => ({
|
||||||
|
showToast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { PostEditor } from '../../../src/renderer/components/Editor/PostEditor';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
const createPost = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Bookmarklet Post',
|
||||||
|
content: '[Example](https://example.com)',
|
||||||
|
excerpt: '',
|
||||||
|
slug: 'bookmarklet-post',
|
||||||
|
status: 'draft' as const,
|
||||||
|
tags: [],
|
||||||
|
categories: ['article'],
|
||||||
|
featuredImage: null,
|
||||||
|
publishedAt: null,
|
||||||
|
createdAt: new Date('2026-03-13T12:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-03-13T12:00:00.000Z'),
|
||||||
|
author: undefined,
|
||||||
|
metadata: {},
|
||||||
|
seoTitle: undefined,
|
||||||
|
seoDescription: undefined,
|
||||||
|
canonicalUrl: undefined,
|
||||||
|
projectId: 'project-1',
|
||||||
|
filePath: '',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Editor keeps canonical language focus on post creation', () => {
|
||||||
|
let metadataResolve: (value: unknown) => void;
|
||||||
|
let eventHandlers: Map<string, EventListener[]>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
monacoOnChange = null;
|
||||||
|
eventHandlers = new Map();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const neverSettles = new Promise<never>(() => {});
|
||||||
|
|
||||||
|
(window as any).addEventListener = vi.fn((event: string, handler: EventListener) => {
|
||||||
|
if (!eventHandlers.has(event)) eventHandlers.set(event, []);
|
||||||
|
eventHandlers.get(event)!.push(handler);
|
||||||
|
});
|
||||||
|
(window as any).removeEventListener = vi.fn((event: string, handler: EventListener) => {
|
||||||
|
const handlers = eventHandlers.get(event);
|
||||||
|
if (handlers) {
|
||||||
|
const idx = handlers.indexOf(handler);
|
||||||
|
if (idx >= 0) handlers.splice(idx, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const metadataPromise = new Promise((resolve) => {
|
||||||
|
metadataResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ language: null }));
|
||||||
|
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
|
||||||
|
(window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null);
|
||||||
|
(window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://localhost:4123/preview');
|
||||||
|
(window as any).electronAPI.posts.getTranslations = vi.fn().mockResolvedValue([]);
|
||||||
|
(window as any).electronAPI.posts.upsertTranslation = vi.fn().mockResolvedValue(null);
|
||||||
|
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||||
|
(window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockReturnValue(metadataPromise);
|
||||||
|
(window as any).electronAPI.templates.getEnabledByKind = vi.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test Blog', path: '/tmp/test' } as any,
|
||||||
|
preferredEditorMode: 'markdown',
|
||||||
|
posts: [],
|
||||||
|
media: [],
|
||||||
|
dirtyPosts: new Set<string>(),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAppStore.setState({
|
||||||
|
dirtyPosts: new Set<string>(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates activeEditingLanguage when projectLanguage loads after initialization', async () => {
|
||||||
|
render(<PostEditor postId="post-1" />);
|
||||||
|
|
||||||
|
// Wait for post data to load and initialization to complete
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Before metadata resolves, the active flag should be 'en' (default projectLanguage)
|
||||||
|
let flagContainer = screen.getByLabelText(/translations/i);
|
||||||
|
let activeButton = flagContainer.querySelector('.active');
|
||||||
|
expect(activeButton).toBeTruthy();
|
||||||
|
expect(activeButton?.getAttribute('aria-label')).toContain('English');
|
||||||
|
|
||||||
|
// Now resolve projectMetadata with mainLanguage = 'de'
|
||||||
|
await act(async () => {
|
||||||
|
metadataResolve({ mainLanguage: 'de' });
|
||||||
|
// Let React process the state updates
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// After metadata resolves, the active flag should be 'de' (the canonical language)
|
||||||
|
// Re-query DOM since buttons may have been re-rendered
|
||||||
|
flagContainer = screen.getByLabelText(/translations/i);
|
||||||
|
activeButton = flagContainer.querySelector('.active');
|
||||||
|
expect(activeButton).toBeTruthy();
|
||||||
|
expect(activeButton?.getAttribute('aria-label')).toContain('German');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create translation draft when typing after projectLanguage loads', async () => {
|
||||||
|
render(<PostEditor postId="post-1" />);
|
||||||
|
|
||||||
|
// Wait for post data to load and initialization to complete
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve projectMetadata with mainLanguage = 'de'
|
||||||
|
await act(async () => {
|
||||||
|
metadataResolve({ mainLanguage: 'de' });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// User types in the editor - this should go to canonical, not create a translation
|
||||||
|
await act(async () => {
|
||||||
|
monacoOnChange?.('[Example](https://example.com)\n\nNew paragraph');
|
||||||
|
});
|
||||||
|
|
||||||
|
// No translation flag should appear for 'en' translation
|
||||||
|
const flagContainer = screen.getByLabelText(/translations/i);
|
||||||
|
const allButtons = flagContainer.querySelectorAll('button');
|
||||||
|
// Should only have 1 button (canonical 'de'), no 'en' translation button
|
||||||
|
expect(allButtons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps canonical language focus when post has explicit language matching project', async () => {
|
||||||
|
// Post created via IPC handler with language set explicitly
|
||||||
|
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ language: 'de' }));
|
||||||
|
|
||||||
|
render(<PostEditor postId="post-1" />);
|
||||||
|
|
||||||
|
// Wait for initialization
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve projectMetadata (even though post already has language)
|
||||||
|
await act(async () => {
|
||||||
|
metadataResolve({ mainLanguage: 'de' });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The active flag should be 'de' (canonical)
|
||||||
|
const flagContainer = screen.getByLabelText(/translations/i);
|
||||||
|
const activeButton = flagContainer.querySelector('.active');
|
||||||
|
expect(activeButton).toBeTruthy();
|
||||||
|
expect(activeButton?.getAttribute('aria-label')).toContain('German');
|
||||||
|
|
||||||
|
// Typing should not create translations
|
||||||
|
await act(async () => {
|
||||||
|
monacoOnChange?.('[Example](https://example.com)\n\nNew content');
|
||||||
|
});
|
||||||
|
|
||||||
|
const allButtons = screen.getByLabelText(/translations/i).querySelectorAll('button');
|
||||||
|
expect(allButtons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user