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:
Georg Bauer
2026-03-13 13:27:45 +01:00
committed by GitHub
parent 101036e58e
commit 914af9831d
11 changed files with 1485 additions and 312 deletions

View 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;
}

View File

@@ -44,9 +44,10 @@ import {
buildRequestedArchiveMaps,
selectRequestedPosts,
} from './ApplyValidationDataService';
import { buildApplyValidationWorkerTasks } from './ApplyValidationWorkerService';
import { getGeneratedFileHashRecord } from '../database/generatedFileHashStore';
import { getAllGeneratedFileHashes, setGeneratedFileHash } from '../database/generatedFileHashStore';
import { GenerationWorkerPool, type WorkerPoolResult } from './GenerationWorkerPool';
import { GenerationWorkerPool } from './GenerationWorkerPool';
import {
serializePostData,
serializeMediaItem,
@@ -54,7 +55,6 @@ import {
serializePostMap,
serializeDateMap,
type GenerationWorkerTask,
type SerializedPostData,
} from './GenerationWorkerData';
import { readPostTranslationFile } from './postTranslationFileUtils';
@@ -130,6 +130,34 @@ export interface SiteValidationApplyResult {
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 {
calendarPath: string;
changed: boolean;
@@ -1543,15 +1571,55 @@ export class BlogGenerationEngine {
report: SiteValidationReport,
onProgress: (progress: number, message?: string) => void,
): 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 updatedPostPaths = Array.isArray(report.updatedPostUrlPaths) ? report.updatedPostUrlPaths : [];
const rerenderPaths = Array.from(new Set([...missingPaths, ...updatedPostPaths]));
const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : [];
onProgress(10, 'Planning validation apply steps...');
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (options.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
@@ -1567,7 +1635,6 @@ export class BlogGenerationEngine {
const pruneEmptyParents = async (startDir: string): Promise<void> => {
let currentDir = startDir;
while (path.resolve(currentDir) !== path.resolve(htmlDir)) {
let entries: string[];
try {
@@ -1575,11 +1642,7 @@ export class BlogGenerationEngine {
} catch {
break;
}
if (entries.length > 0) {
break;
}
if (entries.length > 0) break;
await fs.rm(currentDir, { recursive: true, force: true });
removedEmptyDirCount += 1;
currentDir = path.dirname(currentDir);
@@ -1596,182 +1659,122 @@ export class BlogGenerationEngine {
} catch {
// ignore missing files and continue
}
if (extraPaths.length > 0) {
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) {
onProgress(50, 'Rendering missing routes (fallback section mode)...');
const sectionExecutionOrder: BlogGenerationSection[] = ['category', 'tag', 'date', 'core', 'single'];
for (let index = 0; index < sectionExecutionOrder.length; index += 1) {
const section = sectionExecutionOrder[index];
const generationResult = await this.generate({
...options,
maxPostsPerPage: options.maxPostsPerPage,
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...`);
});
onProgress(100, 'Preparation complete (fallback section mode)');
return {
deletedUrlCount,
removedEmptyDirCount,
requiresFallbackSectionRender: true,
sectionsToRender: ['category', 'tag', 'date', 'core', 'single'],
workerTasksBySection: new Map(),
};
}
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 { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
const { routePosts: publishedRoutePosts } = await this.buildPublishedRoutePosts(publishedPosts);
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
const serializedOptions = serializeBlogGenerationOptions(options);
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({
initialPlan: missingPathPlan,
publishedPosts: publishedRoutePosts,
allCategories,
allTags,
availableYearMonths: yearMonths.keys(),
availableYearMonthDays: yearMonthDays.keys(),
});
let postMediaLinksEntries: Array<[string, Array<{ mediaId: string; sortOrder: number }>]> = [];
if (typeof this.postMediaEngine.getAllPostMediaLinks === 'function') {
const linksMap = await this.postMediaEngine.getAllPostMediaLinks();
postMediaLinksEntries = Array.from(linksMap);
}
const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true });
const generatedHashCache = new Map<string, string | null>();
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({
options,
const mainLangRoutePosts = this.resolvePostsForLanguage(publishedRoutePosts, mainLanguage, translationsByPost, mainLanguage);
const mainLangListPosts = this.resolvePostsForLanguage(publishedListPosts, mainLanguage, translationsByPost, mainLanguage);
const mainLangPostIndex = buildGenerationPostIndex(mainLangListPosts);
const baseWorkerParams = {
options: serializedOptions,
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
mediaItems,
backlinksRecord,
hashMapEntries,
postFilePathEntries,
postMediaLinksEntries,
};
const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({
publishedPosts: publishedRoutePosts,
requestedPostIds: targetedPlan.requestedPostIds,
requestedPageSlugs: targetedPlan.requestedPageSlugs,
});
onProgress(80, 'Building targeted worker tasks...');
const { requestedYearsMap, requestedYearMonthsMap, requestedYearMonthDaysMap } = buildRequestedArchiveMaps({
requestedYears: targetedPlan.requestedYears,
requestedYearMonths: targetedPlan.requestedYearMonths,
requestedYearMonthDays: targetedPlan.requestedYearMonthDays,
const allTasks: GenerationWorkerTask[] = buildApplyValidationWorkerTasks(baseWorkerParams, {
targetedPlan,
publishedRoutePosts: mainLangRoutePosts,
publishedListPosts: mainLangListPosts,
generationPostIndex: mainLangPostIndex,
years,
yearMonths,
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) {
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 langPosts = publishedPosts.filter((p) => !p.doNotTranslate);
const langListPosts = publishedListPosts.filter((p) => !p.doNotTranslate);
const langArchives = buildApplyValidationArchives(langListPosts);
const langTargetedPlan = buildTargetedValidationPlan({
initialPlan: langMissingPlan,
publishedPosts: langPosts,
@@ -1781,138 +1784,386 @@ export class BlogGenerationEngine {
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 resolvedLangPosts = this.resolvePostsForLanguage(langPosts, lang, translationsByPost, mainLanguage);
const resolvedLangListPosts = this.resolvePostsForLanguage(langListPosts, lang, translationsByPost, mainLanguage);
const resolvedLangPostIndex = buildGenerationPostIndex(resolvedLangListPosts);
const langTasks = buildApplyValidationWorkerTasks(
{ ...baseWorkerParams, options: { ...serializedOptions, language: lang } },
{
targetedPlan: langTargetedPlan,
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({
projectId,
htmlDir,
urlPath: `${lang}/${urlPath}`,
content,
refreshHashTimestampOnUnchanged: true,
});
for (const task of allTasks) {
const sectionTasks = workerTasksBySection.get(task.section) ?? [];
sectionTasks.push(task);
workerTasksBySection.set(task.section, sectionTasks);
}
}
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({
projectId: options.projectId,
posts: langListPosts,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
projectId: options.projectId, posts: publishedListPosts, maxPostsPerPage,
renderRoute, writePage, onPageGenerated,
});
const langRequestedPagePosts = selectRequestedPosts({
publishedPosts: langPosts,
requestedPostIds: new Set(),
requestedPageSlugs: langTargetedPlan.requestedPageSlugs,
}).requestedPagePosts;
if (langRequestedPagePosts.length > 0) {
renderedUrlCount += await generatePageRoutes({
projectId: options.projectId,
posts: langRequestedPagePosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
}
if (requestedPagePosts.length > 0) {
renderedUrlCount += await generatePageRoutes({
projectId: options.projectId, posts: requestedPagePosts,
renderRoute, writePage, onPageGenerated,
});
}
break;
case 'single':
if (requestedSinglePosts.length > 0) {
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) {
renderedUrlCount += await generateCategoryPages({
projectId: options.projectId,
posts: langListPosts,
allCategories: langTargetedPlan.requestedCategorySet,
maxPostsPerPage,
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,
case 'date': {
const langArchiveMaps = buildRequestedArchiveMaps({
requestedYears: langTargetedPlan.requestedYears,
requestedYearMonths: langTargetedPlan.requestedYearMonths,
requestedYearMonthDays: langTargetedPlan.requestedYearMonthDays,
years: langArchives.years, yearMonths: langArchives.yearMonths, yearMonthDays: langArchives.yearMonthDays,
});
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(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 (${deletedUrlCount} deleted, ${renderedUrlCount} rendered)`);
return {
renderedUrlCount,
deletedUrlCount,
removedEmptyDirCount,
};
onProgress(100, `${section} rendering complete`);
return renderedUrlCount;
}
}

View File

@@ -282,31 +282,18 @@ export function buildTargetedValidationPlan(params: BuildTargetedValidationPlanP
}
}
for (const year of Array.from(requestedYears.values())) {
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));
}
// Upward cascade only: day → month → year
for (const ymd of Array.from(requestedYearMonthDays.values())) {
const [yearStr, monthStr] = ymd.split('/');
requestedYears.add(Number(yearStr));
requestedYearMonths.add(`${yearStr}/${monthStr}`);
}
for (const ym of Array.from(requestedYearMonths.values())) {
const [yearStr] = ym.split('/');
requestedYears.add(Number(yearStr));
}
return {
requestedPostIds,
requestedCategorySet: new Set(

View File

@@ -5,6 +5,7 @@ import {
type BlogGenerationSection,
type BlogGenerationOptions,
type SiteValidationReport,
type ApplyValidationPreparation,
} from '../engine/BlogGenerationEngine';
import { resolvePageTitle } from '../engine/PageRenderer';
import type { EngineBundle } from '../engine/EngineBundle';
@@ -328,14 +329,71 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
const baseOptions = await resolveBlogGenerationBaseOptions();
const taskTimestamp = Date.now();
return bundle.taskManager.runTask({
id: `site-validate-apply-${taskTimestamp}`,
name: 'Apply Site Validation',
const taskGroupId = `site-validate-apply-${taskTimestamp}`;
const taskGroupName = '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) => {
return blogGenerationEngine.applyValidation(baseOptions, report, (progress, message) => {
onProgress(progress, message || 'Applying site validation...');
return blogGenerationEngine.prepareApplyValidation(baseOptions, report, (progress, message) => {
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,
};
});
}

View File

@@ -449,6 +449,8 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
content: transformResult.post.content,
tags: transformResult.post.tags,
categories: transformResult.post.categories,
language: (metadata as { mainLanguage?: string } | null)?.mainLanguage || undefined,
author: (metadata as { defaultAuthor?: string } | null)?.defaultAuthor || undefined,
});
const blogmarkCreatedPayload = {

View File

@@ -235,6 +235,17 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const canonicalLanguage = postLanguage || post?.language || projectLanguage;
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 result = await window.electronAPI?.posts.getTranslations?.(postId);
const items = result || [];

View 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);
});
});

View File

@@ -1496,7 +1496,7 @@ describe('BlogGenerationEngine', () => {
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 = [
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') }),
@@ -1520,13 +1520,17 @@ describe('BlogGenerationEngine', () => {
existingHtmlUrlCount: 0,
}, 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', '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', '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 = [
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') }),
@@ -1550,9 +1554,11 @@ describe('BlogGenerationEngine', () => {
existingHtmlUrlCount: 0,
}, 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', '01', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(false);
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', '02', 'index.html'))).toBe(false);
expect(await fileExists(path.join(tempDir, 'html', '2024', '02', '20', 'index.html'))).toBe(false);

View File

@@ -86,9 +86,11 @@ describe('ValidationApplyPlannerService', () => {
expect(targeted.requestedTagSet.has('tag-1')).toBe(true);
expect(targeted.requestedYears.has(2025)).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/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.requestRootRoutes).toBe(true);
});

View File

@@ -3236,7 +3236,7 @@ describe('IPC Handlers', () => {
});
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' });
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
@@ -3266,9 +3266,12 @@ describe('IPC Handlers', () => {
existingHtmlUrlCount: 1,
});
// Should run preparation as a grouped task
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Apply Site Validation',
name: 'Prepare Validation Apply',
groupId: expect.stringContaining('site-validate-apply-'),
groupName: 'Apply Site Validation',
execute: expect.any(Function),
}),
);

View 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);
});
});