feat: more on incremental rendering
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
import { getDatabase } from './connection';
|
import { getDatabase } from './connection';
|
||||||
|
|
||||||
|
export interface GeneratedFileHashRecord {
|
||||||
|
contentHash: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGeneratedFileHash(projectId: string, relativePath: string): Promise<string | null> {
|
export async function getGeneratedFileHash(projectId: string, relativePath: string): Promise<string | null> {
|
||||||
const client = getDatabase().getLocalClient();
|
const client = getDatabase().getLocalClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -18,6 +23,33 @@ export async function getGeneratedFileHash(projectId: string, relativePath: stri
|
|||||||
return result.rows[0].content_hash;
|
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> {
|
export async function setGeneratedFileHash(projectId: string, relativePath: string, hash: string): Promise<void> {
|
||||||
const client = getDatabase().getLocalClient();
|
const client = getDatabase().getLocalClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
buildRequestedArchiveMaps,
|
buildRequestedArchiveMaps,
|
||||||
selectRequestedPosts,
|
selectRequestedPosts,
|
||||||
} from './ApplyValidationDataService';
|
} from './ApplyValidationDataService';
|
||||||
|
import { getGeneratedFileHashRecord } from '../database/generatedFileHashStore';
|
||||||
|
|
||||||
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||||
const MIN_MAX_POSTS_PER_PAGE = 1;
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||||
@@ -96,6 +97,7 @@ export interface SiteValidationReport {
|
|||||||
sitemapChanged: boolean;
|
sitemapChanged: boolean;
|
||||||
missingUrlPaths: string[];
|
missingUrlPaths: string[];
|
||||||
extraUrlPaths: string[];
|
extraUrlPaths: string[];
|
||||||
|
updatedPostUrlPaths: string[];
|
||||||
expectedUrlCount: number;
|
expectedUrlCount: number;
|
||||||
existingHtmlUrlCount: number;
|
existingHtmlUrlCount: number;
|
||||||
}
|
}
|
||||||
@@ -375,6 +377,7 @@ export class BlogGenerationEngine {
|
|||||||
content,
|
content,
|
||||||
knownDirectories: knownOutputDirectories,
|
knownDirectories: knownOutputDirectories,
|
||||||
hashCache: generatedHashCache,
|
hashCache: generatedHashCache,
|
||||||
|
refreshHashTimestampOnUnchanged: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let pagesGenerated = 0;
|
let pagesGenerated = 0;
|
||||||
@@ -551,19 +554,40 @@ export class BlogGenerationEngine {
|
|||||||
|
|
||||||
onProgress(50, 'Comparing sitemap to html pages...');
|
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({
|
const diffResult = await compareSitemapToHtml({
|
||||||
sitemapXml,
|
sitemapXml,
|
||||||
baseUrl: options.baseUrl,
|
baseUrl: options.baseUrl,
|
||||||
htmlDir,
|
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 {
|
return {
|
||||||
sitemapPath,
|
sitemapPath,
|
||||||
sitemapChanged,
|
sitemapChanged,
|
||||||
missingUrlPaths: diffResult.missingUrlPaths,
|
missingUrlPaths: diffResult.missingUrlPaths,
|
||||||
extraUrlPaths: diffResult.extraUrlPaths,
|
extraUrlPaths: diffResult.extraUrlPaths,
|
||||||
|
updatedPostUrlPaths: diffResult.updatedPostUrlPaths,
|
||||||
expectedUrlCount: diffResult.expectedUrlCount,
|
expectedUrlCount: diffResult.expectedUrlCount,
|
||||||
existingHtmlUrlCount: diffResult.existingHtmlUrlCount,
|
existingHtmlUrlCount: diffResult.existingHtmlUrlCount,
|
||||||
};
|
};
|
||||||
@@ -577,11 +601,13 @@ export class BlogGenerationEngine {
|
|||||||
onProgress(0, 'Applying validation changes...');
|
onProgress(0, 'Applying validation changes...');
|
||||||
|
|
||||||
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 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...');
|
onProgress(10, 'Planning validation apply steps...');
|
||||||
|
|
||||||
const missingPathPlan = planMissingValidationPaths(missingPaths);
|
const missingPathPlan = planMissingValidationPaths(rerenderPaths);
|
||||||
|
|
||||||
onProgress(20, 'Deleting extra URLs...');
|
onProgress(20, 'Deleting extra URLs...');
|
||||||
|
|
||||||
@@ -686,6 +712,7 @@ export class BlogGenerationEngine {
|
|||||||
htmlDir,
|
htmlDir,
|
||||||
urlPath,
|
urlPath,
|
||||||
content,
|
content,
|
||||||
|
refreshHashTimestampOnUnchanged: true,
|
||||||
});
|
});
|
||||||
const onPageGenerated = (_message: string) => {
|
const onPageGenerated = (_message: string) => {
|
||||||
// no-op for applyValidation
|
// no-op for applyValidation
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export async function writeFileIfHashChanged(params: {
|
|||||||
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
|
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
|
||||||
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
|
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
|
||||||
computeHash?: (content: string) => string;
|
computeHash?: (content: string) => string;
|
||||||
|
refreshHashTimestampOnUnchanged?: boolean;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const getHash = params.getGeneratedFileHash ?? getGeneratedFileHash;
|
const getHash = params.getGeneratedFileHash ?? getGeneratedFileHash;
|
||||||
const setHash = params.setGeneratedFileHash ?? setGeneratedFileHash;
|
const setHash = params.setGeneratedFileHash ?? setGeneratedFileHash;
|
||||||
@@ -75,6 +76,10 @@ export async function writeFileIfHashChanged(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (previousHash === hash) {
|
if (previousHash === hash) {
|
||||||
|
if (params.refreshHashTimestampOnUnchanged) {
|
||||||
|
await setHash(params.projectId, params.relativePath, hash);
|
||||||
|
params.hashCache?.set(params.relativePath, hash);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +100,7 @@ export async function writeHtmlPage(params: {
|
|||||||
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
|
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
|
||||||
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
|
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
|
||||||
computeHash?: (content: string) => string;
|
computeHash?: (content: string) => string;
|
||||||
|
refreshHashTimestampOnUnchanged?: boolean;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const normalizedPath = params.urlPath.replace(/^\//, '');
|
const normalizedPath = params.urlPath.replace(/^\//, '');
|
||||||
const filePath = normalizedPath
|
const filePath = normalizedPath
|
||||||
@@ -124,6 +130,7 @@ export async function writeHtmlPage(params: {
|
|||||||
getGeneratedFileHash: params.getGeneratedFileHash,
|
getGeneratedFileHash: params.getGeneratedFileHash,
|
||||||
setGeneratedFileHash: params.setGeneratedFileHash,
|
setGeneratedFileHash: params.setGeneratedFileHash,
|
||||||
computeHash: params.computeHash,
|
computeHash: params.computeHash,
|
||||||
|
refreshHashTimestampOnUnchanged: params.refreshHashTimestampOnUnchanged,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ import * as path from 'node:path';
|
|||||||
export interface SiteValidationDiffResult {
|
export interface SiteValidationDiffResult {
|
||||||
missingUrlPaths: string[];
|
missingUrlPaths: string[];
|
||||||
extraUrlPaths: string[];
|
extraUrlPaths: string[];
|
||||||
|
updatedPostUrlPaths: string[];
|
||||||
expectedUrlCount: number;
|
expectedUrlCount: number;
|
||||||
existingHtmlUrlCount: number;
|
existingHtmlUrlCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostTimestampCheck {
|
||||||
|
postUrlPath: string;
|
||||||
|
postFilePath: string;
|
||||||
|
generatedUpdatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface CompareSitemapToHtmlParams {
|
interface CompareSitemapToHtmlParams {
|
||||||
sitemapXml: string;
|
sitemapXml: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
htmlDir: string;
|
htmlDir: string;
|
||||||
|
postTimestampChecks?: PostTimestampCheck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUrlPath(urlPath: string): string {
|
function normalizeUrlPath(urlPath: string): string {
|
||||||
@@ -127,9 +135,46 @@ export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams):
|
|||||||
.filter((value, index, array) => array.indexOf(value) === index)
|
.filter((value, index, array) => array.indexOf(value) === index)
|
||||||
.sort();
|
.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 {
|
return {
|
||||||
missingUrlPaths,
|
missingUrlPaths,
|
||||||
extraUrlPaths,
|
extraUrlPaths,
|
||||||
|
updatedPostUrlPaths,
|
||||||
expectedUrlCount: expectedPathSet.size,
|
expectedUrlCount: expectedPathSet.size,
|
||||||
existingHtmlUrlCount: existingHtmlPathSet.size,
|
existingHtmlUrlCount: existingHtmlPathSet.size,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ export interface SiteValidationReport {
|
|||||||
sitemapChanged: boolean;
|
sitemapChanged: boolean;
|
||||||
missingUrlPaths: string[];
|
missingUrlPaths: string[];
|
||||||
extraUrlPaths: string[];
|
extraUrlPaths: string[];
|
||||||
|
updatedPostUrlPaths: string[];
|
||||||
expectedUrlCount: number;
|
expectedUrlCount: number;
|
||||||
existingHtmlUrlCount: number;
|
existingHtmlUrlCount: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type SiteValidationReport = {
|
|||||||
sitemapChanged: boolean;
|
sitemapChanged: boolean;
|
||||||
missingUrlPaths: string[];
|
missingUrlPaths: string[];
|
||||||
extraUrlPaths: string[];
|
extraUrlPaths: string[];
|
||||||
|
updatedPostUrlPaths: string[];
|
||||||
expectedUrlCount: number;
|
expectedUrlCount: number;
|
||||||
existingHtmlUrlCount: number;
|
existingHtmlUrlCount: number;
|
||||||
};
|
};
|
||||||
@@ -62,7 +63,8 @@ export const SiteValidationView: React.FC = () => {
|
|||||||
|
|
||||||
const canApply = useMemo(() => {
|
const canApply = useMemo(() => {
|
||||||
if (!report) return false;
|
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]);
|
}, [report]);
|
||||||
|
|
||||||
const handleApply = async () => {
|
const handleApply = async () => {
|
||||||
@@ -101,6 +103,8 @@ export const SiteValidationView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatedPostUrlPaths = Array.isArray(report.updatedPostUrlPaths) ? report.updatedPostUrlPaths : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="site-validation-view">
|
<div className="site-validation-view">
|
||||||
<div className="site-validation-summary">
|
<div className="site-validation-summary">
|
||||||
@@ -110,6 +114,7 @@ export const SiteValidationView: React.FC = () => {
|
|||||||
existing: report.existingHtmlUrlCount,
|
existing: report.existingHtmlUrlCount,
|
||||||
missing: report.missingUrlPaths.length,
|
missing: report.missingUrlPaths.length,
|
||||||
extra: report.extraUrlPaths.length,
|
extra: report.extraUrlPaths.length,
|
||||||
|
updated: updatedPostUrlPaths.length,
|
||||||
})}</p>
|
})}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +144,19 @@ export const SiteValidationView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<div className="site-validation-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",
|
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",
|
||||||
"siteValidation.tabTitle": "Website-Validierung",
|
"siteValidation.tabTitle": "Website-Validierung",
|
||||||
"siteValidation.title": "Website validieren",
|
"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.loading": "Website wird validiert...",
|
||||||
"siteValidation.missingTitle": "Fehlende HTML-URLs (zum Rendern)",
|
"siteValidation.missingTitle": "Fehlende HTML-URLs (zum Rendern)",
|
||||||
"siteValidation.extraTitle": "Nicht referenzierte HTML-URLs (zum Löschen)",
|
"siteValidation.extraTitle": "Nicht referenzierte HTML-URLs (zum Löschen)",
|
||||||
|
"siteValidation.updatedTitle": "Aktualisierte Beitrags-URLs (zum erneuten Rendern)",
|
||||||
"siteValidation.noneMissing": "Keine fehlenden URLs gefunden.",
|
"siteValidation.noneMissing": "Keine fehlenden URLs gefunden.",
|
||||||
"siteValidation.noneExtra": "Keine überzähligen URLs gefunden.",
|
"siteValidation.noneExtra": "Keine überzähligen URLs gefunden.",
|
||||||
|
"siteValidation.noneUpdated": "Keine aktualisierten Beitrags-URLs gefunden.",
|
||||||
"siteValidation.apply": "Anwenden",
|
"siteValidation.apply": "Anwenden",
|
||||||
"siteValidation.applying": "Wird angewendet...",
|
"siteValidation.applying": "Wird angewendet...",
|
||||||
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
|
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
"app.importComplete": "Import complete: {posts} posts, {media} media files",
|
"app.importComplete": "Import complete: {posts} posts, {media} media files",
|
||||||
"siteValidation.tabTitle": "Site Validation",
|
"siteValidation.tabTitle": "Site Validation",
|
||||||
"siteValidation.title": "Validate Site",
|
"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.loading": "Validating site...",
|
||||||
"siteValidation.missingTitle": "Missing HTML URLs (to render)",
|
"siteValidation.missingTitle": "Missing HTML URLs (to render)",
|
||||||
"siteValidation.extraTitle": "Unreferenced HTML URLs (to delete)",
|
"siteValidation.extraTitle": "Unreferenced HTML URLs (to delete)",
|
||||||
|
"siteValidation.updatedTitle": "Updated post URLs (to rerender)",
|
||||||
"siteValidation.noneMissing": "No missing URLs found.",
|
"siteValidation.noneMissing": "No missing URLs found.",
|
||||||
"siteValidation.noneExtra": "No extra URLs found.",
|
"siteValidation.noneExtra": "No extra URLs found.",
|
||||||
|
"siteValidation.noneUpdated": "No updated post URLs found.",
|
||||||
"siteValidation.apply": "Apply",
|
"siteValidation.apply": "Apply",
|
||||||
"siteValidation.applying": "Applying...",
|
"siteValidation.applying": "Applying...",
|
||||||
"siteValidation.error.validate": "Site validation failed",
|
"siteValidation.error.validate": "Site validation failed",
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
"app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia",
|
"app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia",
|
||||||
"siteValidation.tabTitle": "Validación del sitio",
|
"siteValidation.tabTitle": "Validación del sitio",
|
||||||
"siteValidation.title": "Validar 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.loading": "Validando el sitio...",
|
||||||
"siteValidation.missingTitle": "URLs HTML faltantes (para renderizar)",
|
"siteValidation.missingTitle": "URLs HTML faltantes (para renderizar)",
|
||||||
"siteValidation.extraTitle": "URLs HTML no referenciadas (para eliminar)",
|
"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.noneMissing": "No se encontraron URLs faltantes.",
|
||||||
"siteValidation.noneExtra": "No se encontraron URLs sobrantes.",
|
"siteValidation.noneExtra": "No se encontraron URLs sobrantes.",
|
||||||
|
"siteValidation.noneUpdated": "No se encontraron URLs de entradas actualizadas.",
|
||||||
"siteValidation.apply": "Aplicar",
|
"siteValidation.apply": "Aplicar",
|
||||||
"siteValidation.applying": "Aplicando...",
|
"siteValidation.applying": "Aplicando...",
|
||||||
"siteValidation.error.validate": "La validación del sitio falló",
|
"siteValidation.error.validate": "La validación del sitio falló",
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
|
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
|
||||||
"siteValidation.tabTitle": "Validation du site",
|
"siteValidation.tabTitle": "Validation du site",
|
||||||
"siteValidation.title": "Valider le 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.loading": "Validation du site en cours...",
|
||||||
"siteValidation.missingTitle": "URLs HTML manquantes (à rendre)",
|
"siteValidation.missingTitle": "URLs HTML manquantes (à rendre)",
|
||||||
"siteValidation.extraTitle": "URLs HTML non référencées (à supprimer)",
|
"siteValidation.extraTitle": "URLs HTML non référencées (à supprimer)",
|
||||||
|
"siteValidation.updatedTitle": "URLs d’articles mises à jour (à rerendre)",
|
||||||
"siteValidation.noneMissing": "Aucune URL manquante trouvée.",
|
"siteValidation.noneMissing": "Aucune URL manquante trouvée.",
|
||||||
"siteValidation.noneExtra": "Aucune URL en trop trouvée.",
|
"siteValidation.noneExtra": "Aucune URL en trop trouvée.",
|
||||||
|
"siteValidation.noneUpdated": "Aucune URL d’article mise à jour trouvée.",
|
||||||
"siteValidation.apply": "Appliquer",
|
"siteValidation.apply": "Appliquer",
|
||||||
"siteValidation.applying": "Application en cours...",
|
"siteValidation.applying": "Application en cours...",
|
||||||
"siteValidation.error.validate": "Échec de la validation du site",
|
"siteValidation.error.validate": "Échec de la validation du site",
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
|
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
|
||||||
"siteValidation.tabTitle": "Validazione sito",
|
"siteValidation.tabTitle": "Validazione sito",
|
||||||
"siteValidation.title": "Valida 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.loading": "Validazione del sito in corso...",
|
||||||
"siteValidation.missingTitle": "URL HTML mancanti (da renderizzare)",
|
"siteValidation.missingTitle": "URL HTML mancanti (da renderizzare)",
|
||||||
"siteValidation.extraTitle": "URL HTML non referenziati (da eliminare)",
|
"siteValidation.extraTitle": "URL HTML non referenziati (da eliminare)",
|
||||||
|
"siteValidation.updatedTitle": "URL post aggiornati (da rigenerare)",
|
||||||
"siteValidation.noneMissing": "Nessun URL mancante trovato.",
|
"siteValidation.noneMissing": "Nessun URL mancante trovato.",
|
||||||
"siteValidation.noneExtra": "Nessun URL extra trovato.",
|
"siteValidation.noneExtra": "Nessun URL extra trovato.",
|
||||||
|
"siteValidation.noneUpdated": "Nessun URL di post aggiornato trovato.",
|
||||||
"siteValidation.apply": "Applica",
|
"siteValidation.apply": "Applica",
|
||||||
"siteValidation.applying": "Applicazione in corso...",
|
"siteValidation.applying": "Applicazione in corso...",
|
||||||
"siteValidation.error.validate": "Validazione del sito non riuscita",
|
"siteValidation.error.validate": "Validazione del sito non riuscita",
|
||||||
|
|||||||
@@ -7,13 +7,27 @@ import { resolveUiLanguageFromSystemLocale } from '../../src/main/shared/i18n';
|
|||||||
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
|
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
|
||||||
|
|
||||||
const generatedFileHashes = new Map<string, string>();
|
const generatedFileHashes = new Map<string, string>();
|
||||||
|
const generatedFileUpdatedAt = new Map<string, number>();
|
||||||
const getGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string) => {
|
const getGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string) => {
|
||||||
const key = `${projectId}:${relativePath}`;
|
const key = `${projectId}:${relativePath}`;
|
||||||
return generatedFileHashes.get(key) ?? null;
|
return generatedFileHashes.get(key) ?? null;
|
||||||
});
|
});
|
||||||
|
const getGeneratedFileHashRecordMock = vi.fn(async (projectId: string, relativePath: string) => {
|
||||||
|
const key = `${projectId}:${relativePath}`;
|
||||||
|
const contentHash = generatedFileHashes.get(key);
|
||||||
|
if (!contentHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentHash,
|
||||||
|
updatedAt: generatedFileUpdatedAt.get(key) ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
const setGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string, hash: string) => {
|
const setGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string, hash: string) => {
|
||||||
const key = `${projectId}:${relativePath}`;
|
const key = `${projectId}:${relativePath}`;
|
||||||
generatedFileHashes.set(key, hash);
|
generatedFileHashes.set(key, hash);
|
||||||
|
generatedFileUpdatedAt.set(key, Date.now());
|
||||||
});
|
});
|
||||||
const executeDbSql = vi.fn(async (input: { sql: string; args?: unknown[] }) => {
|
const executeDbSql = vi.fn(async (input: { sql: string; args?: unknown[] }) => {
|
||||||
const sqlText = input.sql.replace(/\s+/g, ' ').trim();
|
const sqlText = input.sql.replace(/\s+/g, ' ').trim();
|
||||||
@@ -40,6 +54,7 @@ const executeDbSql = vi.fn(async (input: { sql: string; args?: unknown[] }) => {
|
|||||||
|
|
||||||
vi.mock('../../src/main/database/generatedFileHashStore', () => ({
|
vi.mock('../../src/main/database/generatedFileHashStore', () => ({
|
||||||
getGeneratedFileHash: getGeneratedFileHashMock,
|
getGeneratedFileHash: getGeneratedFileHashMock,
|
||||||
|
getGeneratedFileHashRecord: getGeneratedFileHashRecordMock,
|
||||||
setGeneratedFileHash: setGeneratedFileHashMock,
|
setGeneratedFileHash: setGeneratedFileHashMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -143,6 +158,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
generatedFileHashes.clear();
|
generatedFileHashes.clear();
|
||||||
|
generatedFileUpdatedAt.clear();
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'bds-gen-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'bds-gen-'));
|
||||||
|
|
||||||
const { __mockPostEngine } = await import('../../src/main/engine/PostEngine') as any;
|
const { __mockPostEngine } = await import('../../src/main/engine/PostEngine') as any;
|
||||||
@@ -978,6 +994,107 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(await fileExists(path.join(tempDir, 'html', 'sitemap.xml'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', 'sitemap.xml'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports updated post routes separately when post markdown is newer than generated html', async () => {
|
||||||
|
const post = makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'updated-post',
|
||||||
|
title: 'Updated Post',
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['update-tag'],
|
||||||
|
createdAt: new Date('2026-02-24T10:00:00Z'),
|
||||||
|
});
|
||||||
|
setupPosts([post]);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
await engine.generate({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
const postFilePath = path.join(tempDir, 'posts', '2026', '02', 'updated-post.md');
|
||||||
|
await mkdir(path.dirname(postFilePath), { recursive: true });
|
||||||
|
await writeFile(postFilePath, '# Updated content', 'utf-8');
|
||||||
|
|
||||||
|
const report = await engine.validateSite({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(report.missingUrlPaths).toEqual([]);
|
||||||
|
expect(report.updatedPostUrlPaths).toEqual(['/2026/02/24/updated-post']);
|
||||||
|
|
||||||
|
const applyResult = await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, report, vi.fn());
|
||||||
|
|
||||||
|
expect(applyResult.renderedUrlCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeatedly flag an old unchanged post as updated after a full generation pass', async () => {
|
||||||
|
const post = makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'old-stable-post',
|
||||||
|
title: 'Old Stable Post',
|
||||||
|
createdAt: new Date('2021-02-24T10:00:00Z'),
|
||||||
|
});
|
||||||
|
setupPosts([post]);
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
await engine.generate({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
const canonicalHtmlPath = path.join(tempDir, 'html', '2021', '02', '24', 'old-stable-post', 'index.html');
|
||||||
|
const beforeStat = await stat(canonicalHtmlPath);
|
||||||
|
|
||||||
|
const postFilePath = path.join(tempDir, 'posts', '2021', '02', 'old-stable-post.md');
|
||||||
|
await mkdir(path.dirname(postFilePath), { recursive: true });
|
||||||
|
await writeFile(postFilePath, '# Old Stable Post', 'utf-8');
|
||||||
|
|
||||||
|
const firstReport = await engine.validateSite({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(firstReport.updatedPostUrlPaths).toEqual(['/2021/02/24/old-stable-post']);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
await engine.generate({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
const afterStat = await stat(canonicalHtmlPath);
|
||||||
|
expect(afterStat.mtimeMs).toBe(beforeStat.mtimeMs);
|
||||||
|
|
||||||
|
const secondReport = await engine.validateSite({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(secondReport.updatedPostUrlPaths).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies validation by rendering missing pages and deleting extra pages with folder pruning', async () => {
|
it('applies validation by rendering missing pages and deleting extra pages with folder pruning', async () => {
|
||||||
const posts = [
|
const posts = [
|
||||||
makePost({
|
makePost({
|
||||||
|
|||||||
@@ -90,4 +90,42 @@ describe('SiteValidationDiffService', () => {
|
|||||||
expect(result.expectedUrlCount).toBe(1);
|
expect(result.expectedUrlCount).toBe(1);
|
||||||
expect(result.existingHtmlUrlCount).toBe(0);
|
expect(result.existingHtmlUrlCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects updated post routes when source markdown is newer than generated html', async () => {
|
||||||
|
const tempRoot = path.join('/tmp', makeTempName());
|
||||||
|
const htmlDir = path.join(tempRoot, 'html');
|
||||||
|
const postsDir = path.join(tempRoot, 'posts');
|
||||||
|
|
||||||
|
await mkdir(path.join(htmlDir, '2026', '02', '24', 'updated-post'), { recursive: true });
|
||||||
|
await mkdir(path.join(postsDir, '2026', '02'), { recursive: true });
|
||||||
|
|
||||||
|
const htmlPath = path.join(htmlDir, '2026', '02', '24', 'updated-post', 'index.html');
|
||||||
|
const postPath = path.join(postsDir, '2026', '02', 'updated-post.md');
|
||||||
|
|
||||||
|
await writeFile(htmlPath, '<html>old render</html>', 'utf-8');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
await writeFile(postPath, '# Updated post', 'utf-8');
|
||||||
|
|
||||||
|
const sitemapXml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
' <url><loc>https://example.com/2026/02/24/updated-post/</loc></url>',
|
||||||
|
'</urlset>',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = await compareSitemapToHtml({
|
||||||
|
sitemapXml,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
htmlDir,
|
||||||
|
postTimestampChecks: [{
|
||||||
|
postUrlPath: '/2026/02/24/updated-post',
|
||||||
|
postFilePath: postPath,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.missingUrlPaths).toEqual([]);
|
||||||
|
expect(result.extraUrlPaths).toEqual([]);
|
||||||
|
expect(result.updatedPostUrlPaths).toEqual(['/2026/02/24/updated-post']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user