feat: more on incremental rendering

This commit is contained in:
2026-02-24 23:02:12 +01:00
parent a8b50d610f
commit 5efbcfe03a
13 changed files with 303 additions and 8 deletions

View File

@@ -1,5 +1,10 @@
import { getDatabase } from './connection';
export interface GeneratedFileHashRecord {
contentHash: string;
updatedAt: number;
}
export async function getGeneratedFileHash(projectId: string, relativePath: string): Promise<string | null> {
const client = getDatabase().getLocalClient();
if (!client) {
@@ -18,6 +23,33 @@ export async function getGeneratedFileHash(projectId: string, relativePath: stri
return result.rows[0].content_hash;
}
export async function getGeneratedFileHashRecord(projectId: string, relativePath: string): Promise<GeneratedFileHashRecord | null> {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database client not available');
}
const result = await client.execute({
sql: 'SELECT content_hash, updated_at FROM generated_file_hashes WHERE project_id = ? AND relative_path = ? LIMIT 1',
args: [projectId, relativePath],
});
const row = result.rows[0];
if (!row || typeof row.content_hash !== 'string') {
return null;
}
const rawUpdatedAt = row.updated_at;
const updatedAt = typeof rawUpdatedAt === 'number'
? rawUpdatedAt
: Number(rawUpdatedAt);
return {
contentHash: row.content_hash,
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
};
}
export async function setGeneratedFileHash(projectId: string, relativePath: string, hash: string): Promise<void> {
const client = getDatabase().getLocalClient();
if (!client) {

View File

@@ -44,6 +44,7 @@ import {
buildRequestedArchiveMaps,
selectRequestedPosts,
} from './ApplyValidationDataService';
import { getGeneratedFileHashRecord } from '../database/generatedFileHashStore';
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
const MIN_MAX_POSTS_PER_PAGE = 1;
@@ -96,6 +97,7 @@ export interface SiteValidationReport {
sitemapChanged: boolean;
missingUrlPaths: string[];
extraUrlPaths: string[];
updatedPostUrlPaths: string[];
expectedUrlCount: number;
existingHtmlUrlCount: number;
}
@@ -375,6 +377,7 @@ export class BlogGenerationEngine {
content,
knownDirectories: knownOutputDirectories,
hashCache: generatedHashCache,
refreshHashTimestampOnUnchanged: true,
});
let pagesGenerated = 0;
@@ -551,19 +554,40 @@ export class BlogGenerationEngine {
onProgress(50, 'Comparing sitemap to html pages...');
const postTimestampChecks = await Promise.all(publishedPosts.map(async (post) => {
const createdAt = resolvePostCreatedAt(post);
const year = String(createdAt.getFullYear());
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const postFilePath = path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
const postUrlPath = buildCanonicalPostPath(post);
const relativePath = `${postUrlPath.replace(/^\//, '')}/index.html`;
const generatedRecord = await getGeneratedFileHashRecord(options.projectId, relativePath);
return {
postUrlPath,
postFilePath,
generatedUpdatedAtMs: generatedRecord?.updatedAt,
};
}));
const diffResult = await compareSitemapToHtml({
sitemapXml,
baseUrl: options.baseUrl,
htmlDir,
postTimestampChecks,
});
onProgress(100, `Validation complete (${diffResult.missingUrlPaths.length} missing, ${diffResult.extraUrlPaths.length} extra)`);
onProgress(
100,
`Validation complete (${diffResult.missingUrlPaths.length} missing, ${diffResult.extraUrlPaths.length} extra, ${diffResult.updatedPostUrlPaths.length} updated)`
);
return {
sitemapPath,
sitemapChanged,
missingUrlPaths: diffResult.missingUrlPaths,
extraUrlPaths: diffResult.extraUrlPaths,
updatedPostUrlPaths: diffResult.updatedPostUrlPaths,
expectedUrlCount: diffResult.expectedUrlCount,
existingHtmlUrlCount: diffResult.existingHtmlUrlCount,
};
@@ -577,11 +601,13 @@ export class BlogGenerationEngine {
onProgress(0, 'Applying validation changes...');
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 missingPathPlan = planMissingValidationPaths(missingPaths);
const missingPathPlan = planMissingValidationPaths(rerenderPaths);
onProgress(20, 'Deleting extra URLs...');
@@ -686,6 +712,7 @@ export class BlogGenerationEngine {
htmlDir,
urlPath,
content,
refreshHashTimestampOnUnchanged: true,
});
const onPageGenerated = (_message: string) => {
// no-op for applyValidation

View File

@@ -60,6 +60,7 @@ export async function writeFileIfHashChanged(params: {
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
computeHash?: (content: string) => string;
refreshHashTimestampOnUnchanged?: boolean;
}): Promise<boolean> {
const getHash = params.getGeneratedFileHash ?? getGeneratedFileHash;
const setHash = params.setGeneratedFileHash ?? setGeneratedFileHash;
@@ -75,6 +76,10 @@ export async function writeFileIfHashChanged(params: {
}
if (previousHash === hash) {
if (params.refreshHashTimestampOnUnchanged) {
await setHash(params.projectId, params.relativePath, hash);
params.hashCache?.set(params.relativePath, hash);
}
return false;
}
@@ -95,6 +100,7 @@ export async function writeHtmlPage(params: {
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
computeHash?: (content: string) => string;
refreshHashTimestampOnUnchanged?: boolean;
}): Promise<boolean> {
const normalizedPath = params.urlPath.replace(/^\//, '');
const filePath = normalizedPath
@@ -124,6 +130,7 @@ export async function writeHtmlPage(params: {
getGeneratedFileHash: params.getGeneratedFileHash,
setGeneratedFileHash: params.setGeneratedFileHash,
computeHash: params.computeHash,
refreshHashTimestampOnUnchanged: params.refreshHashTimestampOnUnchanged,
});
}

View File

@@ -4,14 +4,22 @@ import * as path from 'node:path';
export interface SiteValidationDiffResult {
missingUrlPaths: string[];
extraUrlPaths: string[];
updatedPostUrlPaths: string[];
expectedUrlCount: number;
existingHtmlUrlCount: number;
}
export interface PostTimestampCheck {
postUrlPath: string;
postFilePath: string;
generatedUpdatedAtMs?: number;
}
interface CompareSitemapToHtmlParams {
sitemapXml: string;
baseUrl: string;
htmlDir: string;
postTimestampChecks?: PostTimestampCheck[];
}
function normalizeUrlPath(urlPath: string): string {
@@ -127,9 +135,46 @@ export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams):
.filter((value, index, array) => array.indexOf(value) === index)
.sort();
const updatedPostPathSet = new Set<string>();
const postTimestampChecks = Array.isArray(params.postTimestampChecks) ? params.postTimestampChecks : [];
for (const check of postTimestampChecks) {
const normalizedPostUrlPath = normalizeUrlPath(check.postUrlPath);
if (!expectedPathSet.has(normalizedPostUrlPath)) {
continue;
}
if (missingUrlPaths.includes(normalizedPostUrlPath)) {
continue;
}
const htmlPath = path.join(params.htmlDir, normalizedPostUrlPath === '/' ? 'index.html' : normalizedPostUrlPath.slice(1), 'index.html');
let htmlStat: Awaited<ReturnType<typeof fs.stat>>;
let postStat: Awaited<ReturnType<typeof fs.stat>>;
try {
htmlStat = await fs.stat(htmlPath);
postStat = await fs.stat(check.postFilePath);
} catch {
continue;
}
const generatedUpdatedAtMs = typeof check.generatedUpdatedAtMs === 'number'
? check.generatedUpdatedAtMs
: 0;
const effectiveGeneratedAtMs = Math.max(htmlStat.mtimeMs, generatedUpdatedAtMs);
if (postStat.mtimeMs > effectiveGeneratedAtMs) {
updatedPostPathSet.add(normalizedPostUrlPath);
}
}
const updatedPostUrlPaths = Array.from(updatedPostPathSet.values()).sort();
return {
missingUrlPaths,
extraUrlPaths,
updatedPostUrlPaths,
expectedUrlCount: expectedPathSet.size,
existingHtmlUrlCount: existingHtmlPathSet.size,
};

View File

@@ -436,6 +436,7 @@ export interface SiteValidationReport {
sitemapChanged: boolean;
missingUrlPaths: string[];
extraUrlPaths: string[];
updatedPostUrlPaths: string[];
expectedUrlCount: number;
existingHtmlUrlCount: number;
}

View File

@@ -10,6 +10,7 @@ type SiteValidationReport = {
sitemapChanged: boolean;
missingUrlPaths: string[];
extraUrlPaths: string[];
updatedPostUrlPaths: string[];
expectedUrlCount: number;
existingHtmlUrlCount: number;
};
@@ -62,7 +63,8 @@ export const SiteValidationView: React.FC = () => {
const canApply = useMemo(() => {
if (!report) return false;
return report.missingUrlPaths.length > 0 || report.extraUrlPaths.length > 0;
const updatedPostUrlPaths = Array.isArray(report.updatedPostUrlPaths) ? report.updatedPostUrlPaths : [];
return report.missingUrlPaths.length > 0 || report.extraUrlPaths.length > 0 || updatedPostUrlPaths.length > 0;
}, [report]);
const handleApply = async () => {
@@ -101,6 +103,8 @@ export const SiteValidationView: React.FC = () => {
);
}
const updatedPostUrlPaths = Array.isArray(report.updatedPostUrlPaths) ? report.updatedPostUrlPaths : [];
return (
<div className="site-validation-view">
<div className="site-validation-summary">
@@ -110,6 +114,7 @@ export const SiteValidationView: React.FC = () => {
existing: report.existingHtmlUrlCount,
missing: report.missingUrlPaths.length,
extra: report.extraUrlPaths.length,
updated: updatedPostUrlPaths.length,
})}</p>
</div>
@@ -139,6 +144,19 @@ export const SiteValidationView: React.FC = () => {
)}
</section>
<section className="site-validation-section">
<h3>{tr('siteValidation.updatedTitle')}</h3>
{updatedPostUrlPaths.length === 0 ? (
<p className="site-validation-empty">{tr('siteValidation.noneUpdated')}</p>
) : (
<ul className="site-validation-list site-validation-list-missing">
{updatedPostUrlPaths.map((urlPath) => (
<li key={`updated:${urlPath}`}>{urlPath}</li>
))}
</ul>
)}
</section>
<div className="site-validation-actions">
<button
type="button"

View File

@@ -37,12 +37,14 @@
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",
"siteValidation.tabTitle": "Website-Validierung",
"siteValidation.title": "Website validieren",
"siteValidation.summary": "Erwartete URLs: {expected} · Vorhandene HTML-URLs: {existing} · Fehlend: {missing} · Überzählig: {extra}",
"siteValidation.summary": "Erwartete URLs: {expected} · Vorhandene HTML-URLs: {existing} · Fehlend: {missing} · Überzählig: {extra} · Aktualisierte Beiträge: {updated}",
"siteValidation.loading": "Website wird validiert...",
"siteValidation.missingTitle": "Fehlende HTML-URLs (zum Rendern)",
"siteValidation.extraTitle": "Nicht referenzierte HTML-URLs (zum Löschen)",
"siteValidation.updatedTitle": "Aktualisierte Beitrags-URLs (zum erneuten Rendern)",
"siteValidation.noneMissing": "Keine fehlenden URLs gefunden.",
"siteValidation.noneExtra": "Keine überzähligen URLs gefunden.",
"siteValidation.noneUpdated": "Keine aktualisierten Beitrags-URLs gefunden.",
"siteValidation.apply": "Anwenden",
"siteValidation.applying": "Wird angewendet...",
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",

View File

@@ -37,12 +37,14 @@
"app.importComplete": "Import complete: {posts} posts, {media} media files",
"siteValidation.tabTitle": "Site Validation",
"siteValidation.title": "Validate Site",
"siteValidation.summary": "Expected URLs: {expected} · Existing HTML URLs: {existing} · Missing: {missing} · Extra: {extra}",
"siteValidation.summary": "Expected URLs: {expected} · Existing HTML URLs: {existing} · Missing: {missing} · Extra: {extra} · Updated posts: {updated}",
"siteValidation.loading": "Validating site...",
"siteValidation.missingTitle": "Missing HTML URLs (to render)",
"siteValidation.extraTitle": "Unreferenced HTML URLs (to delete)",
"siteValidation.updatedTitle": "Updated post URLs (to rerender)",
"siteValidation.noneMissing": "No missing URLs found.",
"siteValidation.noneExtra": "No extra URLs found.",
"siteValidation.noneUpdated": "No updated post URLs found.",
"siteValidation.apply": "Apply",
"siteValidation.applying": "Applying...",
"siteValidation.error.validate": "Site validation failed",

View File

@@ -37,12 +37,14 @@
"app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia",
"siteValidation.tabTitle": "Validación del sitio",
"siteValidation.title": "Validar sitio",
"siteValidation.summary": "URLs esperadas: {expected} · URLs HTML existentes: {existing} · Faltantes: {missing} · Sobrantes: {extra}",
"siteValidation.summary": "URLs esperadas: {expected} · URLs HTML existentes: {existing} · Faltantes: {missing} · Sobrantes: {extra} · Entradas actualizadas: {updated}",
"siteValidation.loading": "Validando el sitio...",
"siteValidation.missingTitle": "URLs HTML faltantes (para renderizar)",
"siteValidation.extraTitle": "URLs HTML no referenciadas (para eliminar)",
"siteValidation.updatedTitle": "URLs de entradas actualizadas (para volver a renderizar)",
"siteValidation.noneMissing": "No se encontraron URLs faltantes.",
"siteValidation.noneExtra": "No se encontraron URLs sobrantes.",
"siteValidation.noneUpdated": "No se encontraron URLs de entradas actualizadas.",
"siteValidation.apply": "Aplicar",
"siteValidation.applying": "Aplicando...",
"siteValidation.error.validate": "La validación del sitio falló",

View File

@@ -37,12 +37,14 @@
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
"siteValidation.tabTitle": "Validation du site",
"siteValidation.title": "Valider le site",
"siteValidation.summary": "URLs attendues : {expected} · URLs HTML existantes : {existing} · Manquantes : {missing} · En trop : {extra}",
"siteValidation.summary": "URLs attendues : {expected} · URLs HTML existantes : {existing} · Manquantes : {missing} · En trop : {extra} · Articles mis à jour : {updated}",
"siteValidation.loading": "Validation du site en cours...",
"siteValidation.missingTitle": "URLs HTML manquantes (à rendre)",
"siteValidation.extraTitle": "URLs HTML non référencées (à supprimer)",
"siteValidation.updatedTitle": "URLs darticles mises à jour (à rerendre)",
"siteValidation.noneMissing": "Aucune URL manquante trouvée.",
"siteValidation.noneExtra": "Aucune URL en trop trouvée.",
"siteValidation.noneUpdated": "Aucune URL darticle mise à jour trouvée.",
"siteValidation.apply": "Appliquer",
"siteValidation.applying": "Application en cours...",
"siteValidation.error.validate": "Échec de la validation du site",

View File

@@ -37,12 +37,14 @@
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
"siteValidation.tabTitle": "Validazione sito",
"siteValidation.title": "Valida sito",
"siteValidation.summary": "URL attesi: {expected} · URL HTML esistenti: {existing} · Mancanti: {missing} · Extra: {extra}",
"siteValidation.summary": "URL attesi: {expected} · URL HTML esistenti: {existing} · Mancanti: {missing} · Extra: {extra} · Post aggiornati: {updated}",
"siteValidation.loading": "Validazione del sito in corso...",
"siteValidation.missingTitle": "URL HTML mancanti (da renderizzare)",
"siteValidation.extraTitle": "URL HTML non referenziati (da eliminare)",
"siteValidation.updatedTitle": "URL post aggiornati (da rigenerare)",
"siteValidation.noneMissing": "Nessun URL mancante trovato.",
"siteValidation.noneExtra": "Nessun URL extra trovato.",
"siteValidation.noneUpdated": "Nessun URL di post aggiornato trovato.",
"siteValidation.apply": "Applica",
"siteValidation.applying": "Applicazione in corso...",
"siteValidation.error.validate": "Validazione del sito non riuscita",