feat: sitemap validattion
This commit is contained in:
@@ -56,6 +56,21 @@ export interface BlogGenerationResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SiteValidationReport {
|
||||||
|
sitemapPath: string;
|
||||||
|
sitemapChanged: boolean;
|
||||||
|
missingUrlPaths: string[];
|
||||||
|
extraUrlPaths: string[];
|
||||||
|
expectedUrlCount: number;
|
||||||
|
existingHtmlUrlCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteValidationApplyResult {
|
||||||
|
renderedUrlCount: number;
|
||||||
|
deletedUrlCount: number;
|
||||||
|
removedEmptyDirCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolvePublicBaseUrl(publicUrl?: string): string | null {
|
export function resolvePublicBaseUrl(publicUrl?: string): string | null {
|
||||||
const trimmed = (publicUrl || '').trim();
|
const trimmed = (publicUrl || '').trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -141,9 +156,21 @@ function buildSitemapUrl(
|
|||||||
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
||||||
priority: string,
|
priority: string,
|
||||||
): string {
|
): string {
|
||||||
|
const canonicalLoc = (() => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(loc);
|
||||||
|
if (!parsed.pathname.endsWith('/')) {
|
||||||
|
parsed.pathname = `${parsed.pathname}/`;
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return loc.endsWith('/') ? loc : `${loc}/`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
' <url>',
|
' <url>',
|
||||||
` <loc>${escapeXml(loc)}</loc>`,
|
` <loc>${escapeXml(canonicalLoc)}</loc>`,
|
||||||
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
|
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
|
||||||
` <changefreq>${changefreq}</changefreq>`,
|
` <changefreq>${changefreq}</changefreq>`,
|
||||||
` <priority>${priority}</priority>`,
|
` <priority>${priority}</priority>`,
|
||||||
@@ -151,6 +178,78 @@ function buildSitemapUrl(
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUrlPath(urlPath: string): string {
|
||||||
|
const trimmed = (urlPath || '').trim();
|
||||||
|
if (!trimmed || trimmed === '/') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const noQuery = trimmed.split('?')[0]?.split('#')[0] ?? '';
|
||||||
|
const withoutSlashes = noQuery.replace(/^\/+|\/+$/g, '');
|
||||||
|
return withoutSlashes ? `/${withoutSlashes}` : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlPathToHtmlIndexPath(htmlDir: string, urlPath: string): string {
|
||||||
|
const normalizedPath = normalizeUrlPath(urlPath);
|
||||||
|
if (normalizedPath === '/') {
|
||||||
|
return path.join(htmlDir, 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(htmlDir, normalizedPath.slice(1), 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitemapLocToProjectPath(loc: string, baseUrl: string): string {
|
||||||
|
try {
|
||||||
|
const locUrl = new URL(loc);
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
const locPath = locUrl.pathname.replace(/\/+$/, '');
|
||||||
|
const basePath = base.pathname.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (basePath && locPath.startsWith(basePath)) {
|
||||||
|
const stripped = locPath.slice(basePath.length);
|
||||||
|
return normalizeUrlPath(stripped || '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeUrlPath(locPath || '/');
|
||||||
|
} catch {
|
||||||
|
return normalizeUrlPath(loc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSitemapLocs(sitemapXml: string): string[] {
|
||||||
|
const matches = sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g);
|
||||||
|
const locs: string[] = [];
|
||||||
|
for (const match of matches) {
|
||||||
|
const value = match[1]?.trim();
|
||||||
|
if (value) {
|
||||||
|
locs.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return locs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPaginatedSitemapUrls(
|
||||||
|
target: string[],
|
||||||
|
baseUrl: string,
|
||||||
|
basePath: string,
|
||||||
|
totalItems: number,
|
||||||
|
maxPostsPerPage: number,
|
||||||
|
lastmod: string,
|
||||||
|
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
||||||
|
priority: string,
|
||||||
|
): void {
|
||||||
|
if (totalItems <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / maxPostsPerPage));
|
||||||
|
for (let page = 2; page <= totalPages; page += 1) {
|
||||||
|
const normalizedBase = basePath.replace(/\/+$/, '');
|
||||||
|
const pagePath = `${normalizedBase}/page/${page}`;
|
||||||
|
target.push(buildSitemapUrl(`${baseUrl}${pagePath}`, lastmod, changefreq, priority));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function splitParagraphs(markdown: string | null | undefined): string[] {
|
function splitParagraphs(markdown: string | null | undefined): string[] {
|
||||||
const normalizedMarkdown = typeof markdown === 'string' ? markdown : '';
|
const normalizedMarkdown = typeof markdown === 'string' ? markdown : '';
|
||||||
return normalizedMarkdown
|
return normalizedMarkdown
|
||||||
@@ -299,6 +398,7 @@ export class BlogGenerationEngine {
|
|||||||
const years = new Map<number, Date>();
|
const years = new Map<number, Date>();
|
||||||
const yearMonthDays = new Map<string, Date>();
|
const yearMonthDays = new Map<string, Date>();
|
||||||
const postUrls: Array<{ loc: string; lastmod: string }> = [];
|
const postUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||||
|
const pageUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||||
|
|
||||||
for (const post of publishedPosts) {
|
for (const post of publishedPosts) {
|
||||||
const createdAt = resolvePostCreatedAt(post);
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
@@ -306,6 +406,17 @@ export class BlogGenerationEngine {
|
|||||||
const postUrl = `${options.baseUrl}${canonicalPath}`;
|
const postUrl = `${options.baseUrl}${canonicalPath}`;
|
||||||
const updatedAt = post.updatedAt;
|
const updatedAt = post.updatedAt;
|
||||||
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
|
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
|
||||||
|
|
||||||
|
const categories = Array.isArray(post.categories) ? post.categories : [];
|
||||||
|
if (categories.includes('page')) {
|
||||||
|
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
|
||||||
|
if (trimmedSlug.length > 0) {
|
||||||
|
pageUrls.push({
|
||||||
|
loc: `${options.baseUrl}/${trimmedSlug}`,
|
||||||
|
lastmod: updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const post of publishedListPosts) {
|
for (const post of publishedListPosts) {
|
||||||
@@ -338,26 +449,58 @@ export class BlogGenerationEngine {
|
|||||||
|
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
|
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, '', publishedListPosts.length, maxPostsPerPage, latestPostUpdatedAt, 'daily', '0.9');
|
||||||
for (const post of postUrls) {
|
for (const post of postUrls) {
|
||||||
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
|
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
|
||||||
}
|
}
|
||||||
|
for (const page of pageUrls) {
|
||||||
|
urls.push(buildSitemapUrl(page.loc, page.lastmod, 'weekly', '0.7'));
|
||||||
|
}
|
||||||
|
|
||||||
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
|
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||||
|
|
||||||
|
const yearCount = publishedListPosts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
|
||||||
}
|
}
|
||||||
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
|
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||||
|
|
||||||
|
const [yearStr, monthStr] = ym.split('/');
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
const monthCount = publishedListPosts.filter((post) => {
|
||||||
|
const d = resolvePostCreatedAt(post);
|
||||||
|
return d.getFullYear() === year && (d.getMonth() + 1) === month;
|
||||||
|
}).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
|
||||||
}
|
}
|
||||||
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
|
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
||||||
|
|
||||||
|
const [yearStr, monthStr, dayStr] = ymd.split('/');
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
const day = Number(dayStr);
|
||||||
|
const dayCount = publishedListPosts.filter((post) => {
|
||||||
|
const d = resolvePostCreatedAt(post);
|
||||||
|
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
|
||||||
|
}).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const category of Array.from(allCategories).sort()) {
|
for (const category of Array.from(allCategories).sort()) {
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||||
|
|
||||||
|
const categoryCount = publishedListPosts.filter((post) => (post.categories || []).includes(category)).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tag of Array.from(allTags).sort()) {
|
for (const tag of Array.from(allTags).sort()) {
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||||
|
|
||||||
|
const tagCount = publishedListPosts.filter((post) => (post.tags || []).includes(tag)).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress(8, 'Building RSS and Atom feeds...');
|
onProgress(8, 'Building RSS and Atom feeds...');
|
||||||
@@ -568,6 +711,367 @@ export class BlogGenerationEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validateSite(
|
||||||
|
options: BlogGenerationOptions,
|
||||||
|
onProgress: (progress: number, message?: string) => void,
|
||||||
|
): Promise<SiteValidationReport> {
|
||||||
|
onProgress(0, 'Collecting sitemap URLs...');
|
||||||
|
|
||||||
|
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||||
|
const categorySettings = resolveCategorySettings(options.categorySettings);
|
||||||
|
const listExcludedCategories = Object.entries(categorySettings)
|
||||||
|
.filter(([, settings]) => settings.renderInLists === false)
|
||||||
|
.map(([category]) => category);
|
||||||
|
|
||||||
|
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
|
||||||
|
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
|
||||||
|
const publishedListCandidates = await this.postEngine.getPostsFiltered({
|
||||||
|
status: 'published',
|
||||||
|
excludeCategories: listExcludedCategories,
|
||||||
|
});
|
||||||
|
const draftListCandidates = await this.postEngine.getPostsFiltered({
|
||||||
|
status: 'draft',
|
||||||
|
excludeCategories: listExcludedCategories,
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishedSnapshots = await Promise.all(
|
||||||
|
publishedCandidates.map(async (post) => {
|
||||||
|
const snapshot = await this.postEngine.getPublishedVersion(post.id);
|
||||||
|
return snapshot || post;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const draftPublishedSnapshots = await Promise.all(
|
||||||
|
draftCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)),
|
||||||
|
);
|
||||||
|
const publishedListSnapshots = await Promise.all(
|
||||||
|
publishedListCandidates.map(async (post) => {
|
||||||
|
const snapshot = await this.postEngine.getPublishedVersion(post.id);
|
||||||
|
return snapshot || post;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const draftListPublishedSnapshots = await Promise.all(
|
||||||
|
draftListCandidates.map(async (post) => this.postEngine.getPublishedVersion(post.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const publishedPostById = new Map<string, PostData>();
|
||||||
|
for (const post of publishedSnapshots) {
|
||||||
|
publishedPostById.set(post.id, post);
|
||||||
|
}
|
||||||
|
for (const snapshot of draftPublishedSnapshots) {
|
||||||
|
if (snapshot) {
|
||||||
|
publishedPostById.set(snapshot.id, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedPosts = Array.from(publishedPostById.values())
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
|
||||||
|
const publishedListPostById = new Map<string, PostData>();
|
||||||
|
for (const post of publishedListSnapshots) {
|
||||||
|
publishedListPostById.set(post.id, post);
|
||||||
|
}
|
||||||
|
for (const snapshot of draftListPublishedSnapshots) {
|
||||||
|
if (snapshot) {
|
||||||
|
publishedListPostById.set(snapshot.id, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const publishedListPosts = Array.from(publishedListPostById.values())
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
const allCategories = new Set<string>();
|
||||||
|
const yearMonths = new Map<string, Date>();
|
||||||
|
const years = new Map<number, Date>();
|
||||||
|
const yearMonthDays = new Map<string, Date>();
|
||||||
|
const postUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||||
|
const pageUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||||
|
|
||||||
|
for (const post of publishedPosts) {
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||||
|
const postUrl = `${options.baseUrl}${canonicalPath}`;
|
||||||
|
const updatedAt = post.updatedAt;
|
||||||
|
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
|
||||||
|
|
||||||
|
const categories = Array.isArray(post.categories) ? post.categories : [];
|
||||||
|
if (categories.includes('page')) {
|
||||||
|
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
|
||||||
|
if (trimmedSlug.length > 0) {
|
||||||
|
pageUrls.push({
|
||||||
|
loc: `${options.baseUrl}/${trimmedSlug}`,
|
||||||
|
lastmod: updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const post of publishedListPosts) {
|
||||||
|
for (const tag of post.tags || []) allTags.add(tag);
|
||||||
|
for (const category of post.categories || []) allCategories.add(category);
|
||||||
|
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const updatedAt = post.updatedAt;
|
||||||
|
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
const ymKey = `${year}/${month}`;
|
||||||
|
const ymdKey = `${year}/${month}/${day}`;
|
||||||
|
|
||||||
|
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
||||||
|
yearMonths.set(ymKey, updatedAt);
|
||||||
|
}
|
||||||
|
if (!years.has(year) || updatedAt > years.get(year)!) {
|
||||||
|
years.set(year, updatedAt);
|
||||||
|
}
|
||||||
|
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
||||||
|
yearMonthDays.set(ymdKey, updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
|
||||||
|
|
||||||
|
const urls: string[] = [];
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, '', publishedListPosts.length, maxPostsPerPage, latestPostUpdatedAt, 'daily', '0.9');
|
||||||
|
for (const post of postUrls) {
|
||||||
|
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
|
||||||
|
}
|
||||||
|
for (const page of pageUrls) {
|
||||||
|
urls.push(buildSitemapUrl(page.loc, page.lastmod, 'weekly', '0.7'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||||
|
|
||||||
|
const yearCount = publishedListPosts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/${year}`, yearCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
|
||||||
|
}
|
||||||
|
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||||
|
|
||||||
|
const [yearStr, monthStr] = ym.split('/');
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
const monthCount = publishedListPosts.filter((post) => {
|
||||||
|
const d = resolvePostCreatedAt(post);
|
||||||
|
return d.getFullYear() === year && (d.getMonth() + 1) === month;
|
||||||
|
}).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ym}`, monthCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.4');
|
||||||
|
}
|
||||||
|
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
||||||
|
|
||||||
|
const [yearStr, monthStr, dayStr] = ymd.split('/');
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
const day = Number(dayStr);
|
||||||
|
const dayCount = publishedListPosts.filter((post) => {
|
||||||
|
const d = resolvePostCreatedAt(post);
|
||||||
|
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
|
||||||
|
}).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/${ymd}`, dayCount, maxPostsPerPage, lastmod.toISOString(), 'monthly', '0.3');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const category of Array.from(allCategories).sort()) {
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||||
|
|
||||||
|
const categoryCount = publishedListPosts.filter((post) => (post.categories || []).includes(category)).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/category/${encodeURIComponent(category)}`, categoryCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of Array.from(allTags).sort()) {
|
||||||
|
urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
||||||
|
|
||||||
|
const tagCount = publishedListPosts.filter((post) => (post.tags || []).includes(tag)).length;
|
||||||
|
appendPaginatedSitemapUrls(urls, options.baseUrl, `/tag/${encodeURIComponent(tag)}`, tagCount, maxPostsPerPage, latestPostUpdatedAt, 'weekly', '0.5');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitemapXml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...urls,
|
||||||
|
'</urlset>',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const htmlDir = path.join(options.dataDir, 'html');
|
||||||
|
await fs.mkdir(htmlDir, { recursive: true });
|
||||||
|
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
|
||||||
|
const sitemapChanged = await writeFileIfHashChanged(options.projectId, sitemapPath, 'sitemap.xml', sitemapXml);
|
||||||
|
|
||||||
|
onProgress(50, 'Comparing sitemap to html pages...');
|
||||||
|
|
||||||
|
const expectedPathSet = new Set(
|
||||||
|
extractSitemapLocs(sitemapXml)
|
||||||
|
.map((loc) => sitemapLocToProjectPath(loc, options.baseUrl))
|
||||||
|
.map((value) => normalizeUrlPath(value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingHtmlPathSet = new Set<string>();
|
||||||
|
const collectIndexPaths = async (dir: string, relativePrefix = ''): Promise<void> => {
|
||||||
|
let entries: Array<{ name: string; isDirectory: () => boolean; isFile: () => boolean }>;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nextRelative = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
|
||||||
|
const nextPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await collectIndexPaths(nextPath, nextRelative);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile() || entry.name !== 'index.html') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRelative = nextRelative.replace(/(^|\/)index\.html$/, '');
|
||||||
|
existingHtmlPathSet.add(normalizeUrlPath(normalizedRelative ? `/${normalizedRelative}` : '/'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await collectIndexPaths(htmlDir);
|
||||||
|
|
||||||
|
const missingUrlPaths = Array.from(expectedPathSet)
|
||||||
|
.filter((value) => !existingHtmlPathSet.has(value))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const extraUrlPaths = Array.from(existingHtmlPathSet)
|
||||||
|
.filter((value) => !expectedPathSet.has(value))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
onProgress(100, `Validation complete (${missingUrlPaths.length} missing, ${extraUrlPaths.length} extra)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sitemapPath,
|
||||||
|
sitemapChanged,
|
||||||
|
missingUrlPaths,
|
||||||
|
extraUrlPaths,
|
||||||
|
expectedUrlCount: expectedPathSet.size,
|
||||||
|
existingHtmlUrlCount: existingHtmlPathSet.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyValidation(
|
||||||
|
options: BlogGenerationOptions,
|
||||||
|
report: SiteValidationReport,
|
||||||
|
onProgress: (progress: number, message?: string) => void,
|
||||||
|
): Promise<SiteValidationApplyResult> {
|
||||||
|
onProgress(0, 'Applying validation changes...');
|
||||||
|
|
||||||
|
const missingPaths = Array.isArray(report.missingUrlPaths) ? report.missingUrlPaths : [];
|
||||||
|
const extraPaths = Array.isArray(report.extraUrlPaths) ? report.extraUrlPaths : [];
|
||||||
|
|
||||||
|
const sections = new Set<BlogGenerationSection>();
|
||||||
|
for (const missingPath of missingPaths) {
|
||||||
|
const normalizedPath = normalizeUrlPath(missingPath);
|
||||||
|
|
||||||
|
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
|
||||||
|
sections.add('core');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/category\//.test(normalizedPath)) {
|
||||||
|
sections.add('category');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/tag\//.test(normalizedPath)) {
|
||||||
|
sections.add('tag');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/\d{4}\/\d{2}\/\d{2}\/[^/]+$/.test(normalizedPath)) {
|
||||||
|
sections.add('single');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/\d{4}(?:\/\d{2}(?:\/\d{2})?)?(?:\/page\/\d+)?$/.test(normalizedPath)) {
|
||||||
|
sections.add('date');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/[^/]+$/.test(normalizedPath)) {
|
||||||
|
sections.add('core');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.clear();
|
||||||
|
sections.add('core');
|
||||||
|
sections.add('single');
|
||||||
|
sections.add('category');
|
||||||
|
sections.add('tag');
|
||||||
|
sections.add('date');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderedUrlCount = 0;
|
||||||
|
|
||||||
|
if (sections.size > 0) {
|
||||||
|
onProgress(20, 'Rendering missing URLs...');
|
||||||
|
const generationResult = await this.generate({
|
||||||
|
...options,
|
||||||
|
maxPostsPerPage: options.maxPostsPerPage,
|
||||||
|
sections: Array.from(sections),
|
||||||
|
}, (progress, message) => {
|
||||||
|
onProgress(Math.min(70, 20 + Math.floor(progress * 0.5)), message);
|
||||||
|
});
|
||||||
|
renderedUrlCount = generationResult.pagesGenerated;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(75, 'Deleting extra URLs...');
|
||||||
|
|
||||||
|
const htmlDir = path.join(options.dataDir, 'html');
|
||||||
|
let deletedUrlCount = 0;
|
||||||
|
let removedEmptyDirCount = 0;
|
||||||
|
|
||||||
|
const pruneEmptyParents = async (startDir: string): Promise<void> => {
|
||||||
|
let currentDir = startDir;
|
||||||
|
|
||||||
|
while (path.resolve(currentDir) !== path.resolve(htmlDir)) {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(currentDir);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(currentDir, { recursive: true, force: true });
|
||||||
|
removedEmptyDirCount += 1;
|
||||||
|
currentDir = path.dirname(currentDir);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const urlPath of extraPaths) {
|
||||||
|
const filePath = urlPathToHtmlIndexPath(htmlDir, urlPath);
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
deletedUrlCount += 1;
|
||||||
|
await pruneEmptyParents(path.dirname(filePath));
|
||||||
|
} catch {
|
||||||
|
// ignore missing files and continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(100, `Apply complete (${renderedUrlCount} rendered, ${deletedUrlCount} deleted)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderedUrlCount,
|
||||||
|
deletedUrlCount,
|
||||||
|
removedEmptyDirCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async generatePageRoutes(
|
private async generatePageRoutes(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
posts: PostData[],
|
posts: PostData[],
|
||||||
|
|||||||
@@ -10,19 +10,20 @@ import {
|
|||||||
resolvePublicBaseUrl,
|
resolvePublicBaseUrl,
|
||||||
type BlogGenerationResult,
|
type BlogGenerationResult,
|
||||||
type BlogGenerationSection,
|
type BlogGenerationSection,
|
||||||
|
type BlogGenerationOptions,
|
||||||
|
type SiteValidationReport,
|
||||||
} from '../engine/BlogGenerationEngine';
|
} from '../engine/BlogGenerationEngine';
|
||||||
import { resolvePageTitle } from '../engine/PageRenderer';
|
import { resolvePageTitle } from '../engine/PageRenderer';
|
||||||
|
|
||||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||||
|
|
||||||
export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
||||||
safeHandle('blog:generateSitemap', async () => {
|
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
|
||||||
const projectEngine = getProjectEngine();
|
const projectEngine = getProjectEngine();
|
||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = getMetaEngine();
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = getMediaEngine();
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = getPostMediaEngine();
|
||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
|
||||||
|
|
||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -51,12 +52,10 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
throw new Error('Project public URL is not configured');
|
throw new Error('Project public URL is not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskTimestamp = Date.now();
|
|
||||||
const taskGroupId = `site-render-${taskTimestamp}`;
|
|
||||||
const taskGroupName = 'Render Site';
|
|
||||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||||
const pageTitle = resolvePageTitle(metadata, project.name, project.description ?? undefined);
|
const pageTitle = resolvePageTitle(metadata, project.name, project.description ?? undefined);
|
||||||
const baseOptions = {
|
|
||||||
|
return {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: metadata?.name?.trim() || project.name,
|
projectName: metadata?.name?.trim() || project.name,
|
||||||
projectDescription: metadata?.description,
|
projectDescription: metadata?.description,
|
||||||
@@ -68,6 +67,15 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
picoTheme: metadata?.picoTheme,
|
picoTheme: metadata?.picoTheme,
|
||||||
categorySettings: (metadata as any)?.categorySettings,
|
categorySettings: (metadata as any)?.categorySettings,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
safeHandle('blog:generateSitemap', async () => {
|
||||||
|
const blogGenerationEngine = getBlogGenerationEngine();
|
||||||
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
|
const taskTimestamp = Date.now();
|
||||||
|
const taskGroupId = `site-render-${taskTimestamp}`;
|
||||||
|
const taskGroupName = 'Render Site';
|
||||||
|
|
||||||
const runSectionTask = async (
|
const runSectionTask = async (
|
||||||
section: BlogGenerationSection,
|
section: BlogGenerationSection,
|
||||||
@@ -133,4 +141,18 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
|
|
||||||
return mergeResults([coreResult, singleResult, categoryResult, tagResult, dateResult]);
|
return mergeResults([coreResult, singleResult, categoryResult, tagResult, dateResult]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('blog:validateSite', async () => {
|
||||||
|
const blogGenerationEngine = getBlogGenerationEngine();
|
||||||
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
|
return blogGenerationEngine.validateSite(baseOptions, () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('blog:applyValidation', async (_event, report: SiteValidationReport) => {
|
||||||
|
const blogGenerationEngine = getBlogGenerationEngine();
|
||||||
|
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||||
|
|
||||||
|
return blogGenerationEngine.applyValidation(baseOptions, report, () => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import type { ElectronAPI } from './shared/electronApi';
|
import type { ElectronAPI } from './shared/electronApi';
|
||||||
import type { GitInitProgress } from './shared/electronApi';
|
import type { GitInitProgress } from './shared/electronApi';
|
||||||
|
import type { SiteValidationReport } from './shared/electronApi';
|
||||||
|
|
||||||
// Expose protected methods that allow the renderer process to use
|
// Expose protected methods that allow the renderer process to use
|
||||||
// ipcRenderer without exposing the entire object
|
// ipcRenderer without exposing the entire object
|
||||||
@@ -253,6 +254,8 @@ export const electronAPI: ElectronAPI = {
|
|||||||
// Blog operations
|
// Blog operations
|
||||||
blog: {
|
blog: {
|
||||||
generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'),
|
generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'),
|
||||||
|
validateSite: () => ipcRenderer.invoke('blog:validateSite'),
|
||||||
|
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
|
||||||
},
|
},
|
||||||
|
|
||||||
// AI Chat (OpenCode Zen API integration)
|
// AI Chat (OpenCode Zen API integration)
|
||||||
|
|||||||
@@ -407,6 +407,21 @@ export interface ChatTitleUpdate {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SiteValidationReport {
|
||||||
|
sitemapPath: string;
|
||||||
|
sitemapChanged: boolean;
|
||||||
|
missingUrlPaths: string[];
|
||||||
|
extraUrlPaths: string[];
|
||||||
|
expectedUrlCount: number;
|
||||||
|
existingHtmlUrlCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteValidationApplyResult {
|
||||||
|
renderedUrlCount: number;
|
||||||
|
deletedUrlCount: number;
|
||||||
|
removedEmptyDirCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
git: {
|
git: {
|
||||||
checkAvailability: () => Promise<GitAvailability>;
|
checkAvailability: () => Promise<GitAvailability>;
|
||||||
@@ -611,6 +626,8 @@ export interface ElectronAPI {
|
|||||||
archiveCount: number;
|
archiveCount: number;
|
||||||
pagesGenerated: number;
|
pagesGenerated: number;
|
||||||
}>;
|
}>;
|
||||||
|
validateSite: () => Promise<SiteValidationReport>;
|
||||||
|
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
|
||||||
};
|
};
|
||||||
chat: {
|
chat: {
|
||||||
// API Key Management
|
// API Key Management
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.reindexText": "Suchtext neu indizieren",
|
"menu.item.reindexText": "Suchtext neu indizieren",
|
||||||
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
|
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
|
||||||
"menu.item.generateSitemap": "Site rendern",
|
"menu.item.generateSitemap": "Site rendern",
|
||||||
|
"menu.item.validateSite": "Website validieren",
|
||||||
"menu.item.about": "Über Blogging Desktop Server",
|
"menu.item.about": "Über Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Dokumentation öffnen",
|
"menu.item.openDocumentation": "Dokumentation öffnen",
|
||||||
"menu.item.viewOnGitHub": "Auf GitHub ansehen",
|
"menu.item.viewOnGitHub": "Auf GitHub ansehen",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.reindexText": "Reindex Search Text",
|
"menu.item.reindexText": "Reindex Search Text",
|
||||||
"menu.item.metadataDiff": "Metadata Diff Tool",
|
"menu.item.metadataDiff": "Metadata Diff Tool",
|
||||||
"menu.item.generateSitemap": "Render Site",
|
"menu.item.generateSitemap": "Render Site",
|
||||||
|
"menu.item.validateSite": "Validate Site",
|
||||||
"menu.item.about": "About Blogging Desktop Server",
|
"menu.item.about": "About Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Open Documentation",
|
"menu.item.openDocumentation": "Open Documentation",
|
||||||
"menu.item.viewOnGitHub": "View on GitHub",
|
"menu.item.viewOnGitHub": "View on GitHub",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.reindexText": "Reindex Buscar Text",
|
"menu.item.reindexText": "Reindex Buscar Text",
|
||||||
"menu.item.metadataDiff": "Herramienta diff de metadatos",
|
"menu.item.metadataDiff": "Herramienta diff de metadatos",
|
||||||
"menu.item.generateSitemap": "Renderizar sitio",
|
"menu.item.generateSitemap": "Renderizar sitio",
|
||||||
|
"menu.item.validateSite": "Validar sitio",
|
||||||
"menu.item.about": "Acerca de Blogging Desktop Server",
|
"menu.item.about": "Acerca de Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Abrir documentación",
|
"menu.item.openDocumentation": "Abrir documentación",
|
||||||
"menu.item.viewOnGitHub": "Ver en GitHub",
|
"menu.item.viewOnGitHub": "Ver en GitHub",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.reindexText": "Reindex Recherche Text",
|
"menu.item.reindexText": "Reindex Recherche Text",
|
||||||
"menu.item.metadataDiff": "Outil de diff des métadonnées",
|
"menu.item.metadataDiff": "Outil de diff des métadonnées",
|
||||||
"menu.item.generateSitemap": "Rendre le site",
|
"menu.item.generateSitemap": "Rendre le site",
|
||||||
|
"menu.item.validateSite": "Valider le site",
|
||||||
"menu.item.about": "À propos de Blogging Desktop Server",
|
"menu.item.about": "À propos de Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Ouvrir la documentation",
|
"menu.item.openDocumentation": "Ouvrir la documentation",
|
||||||
"menu.item.viewOnGitHub": "Voir sur GitHub",
|
"menu.item.viewOnGitHub": "Voir sur GitHub",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.reindexText": "Reindex Ricerca Text",
|
"menu.item.reindexText": "Reindex Ricerca Text",
|
||||||
"menu.item.metadataDiff": "Strumento diff metadati",
|
"menu.item.metadataDiff": "Strumento diff metadati",
|
||||||
"menu.item.generateSitemap": "Renderizza sito",
|
"menu.item.generateSitemap": "Renderizza sito",
|
||||||
|
"menu.item.validateSite": "Valida sito",
|
||||||
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Apri documentazione",
|
"menu.item.openDocumentation": "Apri documentazione",
|
||||||
"menu.item.viewOnGitHub": "Visualizza su GitHub",
|
"menu.item.viewOnGitHub": "Visualizza su GitHub",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type AppMenuAction =
|
|||||||
| 'reindexText'
|
| 'reindexText'
|
||||||
| 'metadataDiff'
|
| 'metadataDiff'
|
||||||
| 'generateSitemap'
|
| 'generateSitemap'
|
||||||
|
| 'validateSite'
|
||||||
| 'openDocumentation'
|
| 'openDocumentation'
|
||||||
| 'about'
|
| 'about'
|
||||||
| 'viewOnGitHub'
|
| 'viewOnGitHub'
|
||||||
@@ -121,6 +122,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
|||||||
{ label: '', action: 'blog-separator-3', separator: true },
|
{ label: '', action: 'blog-separator-3', separator: true },
|
||||||
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
|
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
|
||||||
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
||||||
|
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Alt+V' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
|||||||
reindexText: 'menu:reindexText',
|
reindexText: 'menu:reindexText',
|
||||||
metadataDiff: 'menu:metadataDiff',
|
metadataDiff: 'menu:metadataDiff',
|
||||||
generateSitemap: 'menu:generateSitemap',
|
generateSitemap: 'menu:generateSitemap',
|
||||||
|
validateSite: 'menu:validateSite',
|
||||||
openDocumentation: 'menu:openDocumentation',
|
openDocumentation: 'menu:openDocumentation',
|
||||||
about: 'menu:about',
|
about: 'menu:about',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -304,6 +304,12 @@ const App: React.FC = () => {
|
|||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('menu:validateSite', () => {
|
||||||
|
openTab({ id: 'site-validation-report', type: 'site-validation', isTransient: true });
|
||||||
|
}) || (() => {})
|
||||||
|
);
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('menu:previewPost', async () => {
|
window.electronAPI?.on('menu:previewPost', async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ImportAnalysisView } from '../ImportAnalysisView';
|
|||||||
import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
||||||
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
||||||
import { DocumentationView } from '../DocumentationView/DocumentationView';
|
import { DocumentationView } from '../DocumentationView/DocumentationView';
|
||||||
|
import { SiteValidationView } from '../SiteValidationView';
|
||||||
import { AutoSaveManager, getContrastColor } from '../../utils';
|
import { AutoSaveManager, getContrastColor } from '../../utils';
|
||||||
import { InsertModal } from '../InsertModal';
|
import { InsertModal } from '../InsertModal';
|
||||||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||||||
@@ -1735,6 +1736,7 @@ export const Editor: React.FC = () => {
|
|||||||
const showMetadataDiff = activeTab?.type === 'metadata-diff';
|
const showMetadataDiff = activeTab?.type === 'metadata-diff';
|
||||||
const showGitDiff = activeTab?.type === 'git-diff';
|
const showGitDiff = activeTab?.type === 'git-diff';
|
||||||
const showDocumentation = activeTab?.type === 'documentation';
|
const showDocumentation = activeTab?.type === 'documentation';
|
||||||
|
const showSiteValidation = activeTab?.type === 'site-validation';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activePostId = activeTab?.type === 'post' ? activeTab.id : null;
|
const activePostId = activeTab?.type === 'post' ? activeTab.id : null;
|
||||||
@@ -1873,6 +1875,16 @@ export const Editor: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSiteValidation) {
|
||||||
|
return (
|
||||||
|
<div className="editor">
|
||||||
|
<SiteValidationView />
|
||||||
|
{renderErrorModal()}
|
||||||
|
{renderConfirmDeleteModal()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show post editor if a post tab is active
|
// Show post editor if a post tab is active
|
||||||
if (showPost && activeTabId) {
|
if (showPost && activeTabId) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.site-validation-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-summary h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-summary p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-section h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-family: var(--vscode-editor-font-family);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-list-missing {
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-list-extra {
|
||||||
|
color: var(--vscode-notificationsErrorIcon-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-empty,
|
||||||
|
.site-validation-status {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-apply {
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-apply:hover:not(:disabled) {
|
||||||
|
background-color: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-validation-apply:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
import './SiteValidationView.css';
|
||||||
|
|
||||||
|
type SiteValidationReport = {
|
||||||
|
sitemapPath: string;
|
||||||
|
sitemapChanged: boolean;
|
||||||
|
missingUrlPaths: string[];
|
||||||
|
extraUrlPaths: string[];
|
||||||
|
expectedUrlCount: number;
|
||||||
|
existingHtmlUrlCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteValidationApplyResult = {
|
||||||
|
renderedUrlCount: number;
|
||||||
|
deletedUrlCount: number;
|
||||||
|
removedEmptyDirCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SiteValidationView: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
const [report, setReport] = useState<SiteValidationReport | null>(null);
|
||||||
|
|
||||||
|
const loadReport = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.blog.validateSite();
|
||||||
|
setReport(result as SiteValidationReport);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Site validation failed:', error);
|
||||||
|
showToast.error(tr('siteValidation.error.validate'));
|
||||||
|
setReport(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadReport();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canApply = useMemo(() => {
|
||||||
|
if (!report) return false;
|
||||||
|
return report.missingUrlPaths.length > 0 || report.extraUrlPaths.length > 0;
|
||||||
|
}, [report]);
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!report || !canApply) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApplying(true);
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.blog.applyValidation(report) as SiteValidationApplyResult;
|
||||||
|
showToast.success(tr('siteValidation.toast.applySuccess', {
|
||||||
|
rendered: result.renderedUrlCount,
|
||||||
|
deleted: result.deletedUrlCount,
|
||||||
|
}));
|
||||||
|
await loadReport();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Applying site validation failed:', error);
|
||||||
|
showToast.error(tr('siteValidation.error.apply'));
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="site-validation-view">
|
||||||
|
<p className="site-validation-status">{tr('siteValidation.loading')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return (
|
||||||
|
<div className="site-validation-view">
|
||||||
|
<p className="site-validation-status">{tr('siteValidation.error.validate')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="site-validation-view">
|
||||||
|
<div className="site-validation-summary">
|
||||||
|
<h2>{tr('siteValidation.title')}</h2>
|
||||||
|
<p>{tr('siteValidation.summary', {
|
||||||
|
expected: report.expectedUrlCount,
|
||||||
|
existing: report.existingHtmlUrlCount,
|
||||||
|
missing: report.missingUrlPaths.length,
|
||||||
|
extra: report.extraUrlPaths.length,
|
||||||
|
})}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="site-validation-section">
|
||||||
|
<h3>{tr('siteValidation.missingTitle')}</h3>
|
||||||
|
{report.missingUrlPaths.length === 0 ? (
|
||||||
|
<p className="site-validation-empty">{tr('siteValidation.noneMissing')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="site-validation-list site-validation-list-missing">
|
||||||
|
{report.missingUrlPaths.map((urlPath) => (
|
||||||
|
<li key={`missing:${urlPath}`}>{urlPath}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="site-validation-section">
|
||||||
|
<h3>{tr('siteValidation.extraTitle')}</h3>
|
||||||
|
{report.extraUrlPaths.length === 0 ? (
|
||||||
|
<p className="site-validation-empty">{tr('siteValidation.noneExtra')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="site-validation-list site-validation-list-extra">
|
||||||
|
{report.extraUrlPaths.map((urlPath) => (
|
||||||
|
<li key={`extra:${urlPath}`}>{urlPath}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="site-validation-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-validation-apply"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={!canApply || isApplying}
|
||||||
|
>
|
||||||
|
{isApplying ? tr('siteValidation.applying') : tr('siteValidation.apply')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/SiteValidationView/index.ts
Normal file
1
src/renderer/components/SiteValidationView/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SiteValidationView } from './SiteValidationView';
|
||||||
@@ -84,6 +84,10 @@ const getTabTitle = (
|
|||||||
return tr('docs.title');
|
return tr('docs.title');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab.type === 'site-validation') {
|
||||||
|
return tr('siteValidation.tabTitle');
|
||||||
|
}
|
||||||
|
|
||||||
return tr('tabBar.unknown');
|
return tr('tabBar.unknown');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,6 +154,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
|||||||
<path d="M5 4h6v1H5V4zm0 2h6v1H5V6zm0 2h6v1H5V8zm0 2h4v1H5v-1z"/>
|
<path d="M5 4h6v1H5V4zm0 2h6v1H5V6zm0 2h6v1H5V8zm0 2h4v1H5v-1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'site-validation':
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ export { ImportAnalysisView } from './ImportAnalysisView';
|
|||||||
export { InsertModal } from './InsertModal';
|
export { InsertModal } from './InsertModal';
|
||||||
export { WindowTitleBar } from './WindowTitleBar';
|
export { WindowTitleBar } from './WindowTitleBar';
|
||||||
export { DocumentationView } from './DocumentationView/DocumentationView';
|
export { DocumentationView } from './DocumentationView/DocumentationView';
|
||||||
|
export { SiteValidationView } from './SiteValidationView';
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
|
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
|
||||||
"app.metadataDiff": "Metadaten-Diff",
|
"app.metadataDiff": "Metadaten-Diff",
|
||||||
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",
|
"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.loading": "Website wird validiert...",
|
||||||
|
"siteValidation.missingTitle": "Fehlende HTML-URLs (zum Rendern)",
|
||||||
|
"siteValidation.extraTitle": "Nicht referenzierte HTML-URLs (zum Löschen)",
|
||||||
|
"siteValidation.noneMissing": "Keine fehlenden URLs gefunden.",
|
||||||
|
"siteValidation.noneExtra": "Keine überzähligen URLs gefunden.",
|
||||||
|
"siteValidation.apply": "Anwenden",
|
||||||
|
"siteValidation.applying": "Wird angewendet...",
|
||||||
|
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
|
||||||
|
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
|
||||||
|
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
|
||||||
"settings.language.english": "Englisch",
|
"settings.language.english": "Englisch",
|
||||||
"settings.language.german": "Deutsch",
|
"settings.language.german": "Deutsch",
|
||||||
"settings.language.french": "Französisch",
|
"settings.language.french": "Französisch",
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
"app.previewOpenFailed": "Failed to open selected post preview",
|
"app.previewOpenFailed": "Failed to open selected post preview",
|
||||||
"app.metadataDiff": "Metadata Diff",
|
"app.metadataDiff": "Metadata Diff",
|
||||||
"app.importComplete": "Import complete: {posts} posts, {media} media files",
|
"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.loading": "Validating site...",
|
||||||
|
"siteValidation.missingTitle": "Missing HTML URLs (to render)",
|
||||||
|
"siteValidation.extraTitle": "Unreferenced HTML URLs (to delete)",
|
||||||
|
"siteValidation.noneMissing": "No missing URLs found.",
|
||||||
|
"siteValidation.noneExtra": "No extra URLs found.",
|
||||||
|
"siteValidation.apply": "Apply",
|
||||||
|
"siteValidation.applying": "Applying...",
|
||||||
|
"siteValidation.error.validate": "Site validation failed",
|
||||||
|
"siteValidation.error.apply": "Applying validation failed",
|
||||||
|
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
|
||||||
"settings.language.english": "English",
|
"settings.language.english": "English",
|
||||||
"settings.language.german": "German",
|
"settings.language.german": "German",
|
||||||
"settings.language.french": "French",
|
"settings.language.french": "French",
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
|
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
|
||||||
"app.metadataDiff": "Diferencia de Metadatos",
|
"app.metadataDiff": "Diferencia de Metadatos",
|
||||||
"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.title": "Validar sitio",
|
||||||
|
"siteValidation.summary": "URLs esperadas: {expected} · URLs HTML existentes: {existing} · Faltantes: {missing} · Sobrantes: {extra}",
|
||||||
|
"siteValidation.loading": "Validando el sitio...",
|
||||||
|
"siteValidation.missingTitle": "URLs HTML faltantes (para renderizar)",
|
||||||
|
"siteValidation.extraTitle": "URLs HTML no referenciadas (para eliminar)",
|
||||||
|
"siteValidation.noneMissing": "No se encontraron URLs faltantes.",
|
||||||
|
"siteValidation.noneExtra": "No se encontraron URLs sobrantes.",
|
||||||
|
"siteValidation.apply": "Aplicar",
|
||||||
|
"siteValidation.applying": "Aplicando...",
|
||||||
|
"siteValidation.error.validate": "La validación del sitio falló",
|
||||||
|
"siteValidation.error.apply": "La aplicación de la validación falló",
|
||||||
|
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
|
||||||
"settings.language.english": "Inglés",
|
"settings.language.english": "Inglés",
|
||||||
"settings.language.german": "Alemán",
|
"settings.language.german": "Alemán",
|
||||||
"settings.language.french": "Francés",
|
"settings.language.french": "Francés",
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
"app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné",
|
"app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné",
|
||||||
"app.metadataDiff": "Diff Métadonnées",
|
"app.metadataDiff": "Diff Métadonnées",
|
||||||
"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.title": "Valider le site",
|
||||||
|
"siteValidation.summary": "URLs attendues : {expected} · URLs HTML existantes : {existing} · Manquantes : {missing} · En trop : {extra}",
|
||||||
|
"siteValidation.loading": "Validation du site en cours...",
|
||||||
|
"siteValidation.missingTitle": "URLs HTML manquantes (à rendre)",
|
||||||
|
"siteValidation.extraTitle": "URLs HTML non référencées (à supprimer)",
|
||||||
|
"siteValidation.noneMissing": "Aucune URL manquante trouvée.",
|
||||||
|
"siteValidation.noneExtra": "Aucune URL en trop trouvée.",
|
||||||
|
"siteValidation.apply": "Appliquer",
|
||||||
|
"siteValidation.applying": "Application en cours...",
|
||||||
|
"siteValidation.error.validate": "Échec de la validation du site",
|
||||||
|
"siteValidation.error.apply": "Échec de l’application de la validation",
|
||||||
|
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
|
||||||
"settings.language.english": "Anglais",
|
"settings.language.english": "Anglais",
|
||||||
"settings.language.german": "Allemand",
|
"settings.language.german": "Allemand",
|
||||||
"settings.language.french": "Français",
|
"settings.language.french": "Français",
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
"app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato",
|
"app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato",
|
||||||
"app.metadataDiff": "Diff Metadati",
|
"app.metadataDiff": "Diff Metadati",
|
||||||
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
|
"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.loading": "Validazione del sito in corso...",
|
||||||
|
"siteValidation.missingTitle": "URL HTML mancanti (da renderizzare)",
|
||||||
|
"siteValidation.extraTitle": "URL HTML non referenziati (da eliminare)",
|
||||||
|
"siteValidation.noneMissing": "Nessun URL mancante trovato.",
|
||||||
|
"siteValidation.noneExtra": "Nessun URL extra trovato.",
|
||||||
|
"siteValidation.apply": "Applica",
|
||||||
|
"siteValidation.applying": "Applicazione in corso...",
|
||||||
|
"siteValidation.error.validate": "Validazione del sito non riuscita",
|
||||||
|
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
|
||||||
|
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
|
||||||
"settings.language.english": "Inglese",
|
"settings.language.english": "Inglese",
|
||||||
"settings.language.german": "Tedesco",
|
"settings.language.german": "Tedesco",
|
||||||
"settings.language.french": "Francese",
|
"settings.language.french": "Francese",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
const STORAGE_KEY = 'bds-app-state';
|
const STORAGE_KEY = 'bds-app-state';
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation';
|
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
type: TabType;
|
type: TabType;
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ const TAB_STATE_PREFIX = 'bds-tabs-';
|
|||||||
|
|
||||||
export const saveTabsForProject = (projectId: string, tabState: TabState): void => {
|
export const saveTabsForProject = (projectId: string, tabState: TabState): void => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`${TAB_STATE_PREFIX}${projectId}`, JSON.stringify(tabState));
|
const persistentTabs = tabState.tabs.filter((tab) => tab.isTransient !== true);
|
||||||
|
const persistedState: TabState = {
|
||||||
|
tabs: persistentTabs,
|
||||||
|
activeTabId: persistentTabs.some((tab) => tab.id === tabState.activeTabId)
|
||||||
|
? tabState.activeTabId
|
||||||
|
: (persistentTabs[0]?.id ?? null),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(`${TAB_STATE_PREFIX}${projectId}`, JSON.stringify(persistedState));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save tab state:', error);
|
console.error('Failed to save tab state:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mkdtemp, readFile, rm, readdir, stat } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, readdir, stat, mkdir, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||||
@@ -442,6 +442,171 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(result.pagesGenerated).toBe(7);
|
expect(result.pagesGenerated).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('validates sitemap against html folder without rendering missing pages', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'validation-main-post',
|
||||||
|
title: 'Validation Main Post',
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['validation-tag'],
|
||||||
|
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
makePost({
|
||||||
|
id: '2',
|
||||||
|
slug: 'validation-page',
|
||||||
|
title: 'Validation Page',
|
||||||
|
categories: ['page'],
|
||||||
|
tags: [],
|
||||||
|
createdAt: new Date('2025-01-16T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
await mkdir(path.join(tempDir, 'html', 'stale'), { recursive: true });
|
||||||
|
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
const report = await engine.validateSite({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(report.missingUrlPaths).toContain('/2025/01/15/validation-main-post');
|
||||||
|
expect(report.missingUrlPaths).toContain('/category/news');
|
||||||
|
expect(report.missingUrlPaths).toContain('/tag/validation-tag');
|
||||||
|
expect(report.missingUrlPaths).toContain('/validation-page');
|
||||||
|
expect(report.extraUrlPaths).toContain('/stale');
|
||||||
|
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'validation-main-post', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'sitemap.xml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies validation by rendering missing pages and deleting extra pages with folder pruning', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'apply-post',
|
||||||
|
title: 'Apply Post',
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['apply-tag'],
|
||||||
|
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
await mkdir(path.join(tempDir, 'html', 'obsolete', 'deep'), { recursive: true });
|
||||||
|
await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), '<html>obsolete</html>', 'utf-8');
|
||||||
|
|
||||||
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
|
const engine = new BlogGenerationEngine();
|
||||||
|
|
||||||
|
const report = await engine.validateSite({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
const applyResult = await engine.applyValidation({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
}, report, vi.fn());
|
||||||
|
|
||||||
|
expect(applyResult.deletedUrlCount).toBeGreaterThan(0);
|
||||||
|
expect(applyResult.renderedUrlCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'obsolete', 'deep'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'obsolete'))).toBe(false);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'apply-post', 'index.html'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not report valid pagination routes as extra html content', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', slug: 'p1', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||||
|
makePost({ id: '2', slug: 'p2', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-14T10:00:00Z') }),
|
||||||
|
makePost({ id: '3', slug: 'p3', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-13T10:00:00Z') }),
|
||||||
|
];
|
||||||
|
|
||||||
|
setupPosts(posts);
|
||||||
|
|
||||||
|
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',
|
||||||
|
maxPostsPerPage: 2,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
const report = await engine.validateSite({
|
||||||
|
projectId: 'test',
|
||||||
|
projectName: 'Test Blog',
|
||||||
|
dataDir: tempDir,
|
||||||
|
baseUrl: 'https://example.com',
|
||||||
|
maxPostsPerPage: 2,
|
||||||
|
}, vi.fn());
|
||||||
|
|
||||||
|
expect(report.extraUrlPaths).not.toContain('/page/2');
|
||||||
|
expect(report.extraUrlPaths).not.toContain('/category/news/page/2');
|
||||||
|
expect(report.extraUrlPaths).not.toContain('/tag/tag-news/page/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits sitemap urls with trailing slash canonical form', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'canonical-post',
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['canonical-tag'],
|
||||||
|
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
makePost({
|
||||||
|
id: '2',
|
||||||
|
slug: 'canonical-post-2',
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['canonical-tag'],
|
||||||
|
createdAt: new Date('2025-01-14T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
makePost({
|
||||||
|
id: '3',
|
||||||
|
slug: 'canonical-post-3',
|
||||||
|
categories: ['news'],
|
||||||
|
tags: ['canonical-tag'],
|
||||||
|
createdAt: new Date('2025-01-13T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
makePost({
|
||||||
|
id: '4',
|
||||||
|
slug: 'canonical-page',
|
||||||
|
categories: ['page'],
|
||||||
|
tags: [],
|
||||||
|
createdAt: new Date('2025-01-12T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await generate(posts, { maxPostsPerPage: 2 });
|
||||||
|
|
||||||
|
const sitemap = await readFile(path.join(tempDir, 'html', 'sitemap.xml'), 'utf-8');
|
||||||
|
|
||||||
|
expect(sitemap).toContain('<loc>https://example.com/</loc>');
|
||||||
|
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/canonical-post/</loc>');
|
||||||
|
expect(sitemap).toContain('<loc>https://example.com/category/news/</loc>');
|
||||||
|
expect(sitemap).toContain('<loc>https://example.com/category/news/page/2/</loc>');
|
||||||
|
expect(sitemap).toContain('<loc>https://example.com/tag/canonical-tag/</loc>');
|
||||||
|
expect(sitemap).toContain('<loc>https://example.com/canonical-page/</loc>');
|
||||||
|
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
||||||
|
});
|
||||||
|
|
||||||
it('generates HTML that references local assets not CDN', async () => {
|
it('generates HTML that references local assets not CDN', async () => {
|
||||||
const posts = [makePost({ id: '1', slug: 'test' })];
|
const posts = [makePost({ id: '1', slug: 'test' })];
|
||||||
await generate(posts);
|
await generate(posts);
|
||||||
|
|||||||
@@ -2246,6 +2246,64 @@ describe('IPC Handlers', () => {
|
|||||||
expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post');
|
expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('blog:validateSite', () => {
|
||||||
|
it('should generate sitemap-only validation report against html folder', async () => {
|
||||||
|
const mockProject = createMockProject({
|
||||||
|
id: 'test-project',
|
||||||
|
dataPath: '/mock/data',
|
||||||
|
});
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||||
|
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||||
|
name: 'Test Project',
|
||||||
|
publicUrl: 'https://blog.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||||
|
if (filter.status === 'published') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'post-1',
|
||||||
|
projectId: 'test-project',
|
||||||
|
title: 'Test Post',
|
||||||
|
slug: 'test-post',
|
||||||
|
excerpt: '',
|
||||||
|
content: '# Test',
|
||||||
|
status: 'published',
|
||||||
|
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||||
|
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||||
|
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
||||||
|
tags: ['tag1'],
|
||||||
|
categories: ['category1'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (filter.status === 'draft') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { writeFile, mkdir, readdir } = await import('fs/promises');
|
||||||
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(readdir).mockResolvedValue([] as never);
|
||||||
|
|
||||||
|
const result = await invokeHandler('blog:validateSite');
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({
|
||||||
|
missingUrlPaths: expect.any(Array),
|
||||||
|
extraUrlPaths: expect.any(Array),
|
||||||
|
}));
|
||||||
|
expect(writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('sitemap.xml'),
|
||||||
|
expect.stringContaining('<?xml version="1.0" encoding="UTF-8"?>'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ Error Handling ============
|
// ============ Error Handling ============
|
||||||
|
|||||||
@@ -40,11 +40,15 @@ describe('Help menu documentation entry', () => {
|
|||||||
expect(viewGroup?.items.some((item) => item.action === 'toggleFullScreen')).toBe(true);
|
expect(viewGroup?.items.some((item) => item.action === 'toggleFullScreen')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('assigns Command/Ctrl+R shortcut for generateSitemap menu item', () => {
|
it('includes Validate Site action in Blog menu with a V shortcut', () => {
|
||||||
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||||
const generateSiteItem = blogGroup?.items.find((item) => item.action === 'generateSitemap');
|
const validateSiteItem = blogGroup?.items.find((item) => item.action === 'validateSite');
|
||||||
|
|
||||||
expect(generateSiteItem).toBeDefined();
|
expect(validateSiteItem).toBeDefined();
|
||||||
expect(generateSiteItem?.accelerator).toBe('CmdOrCtrl+R');
|
expect(validateSiteItem?.accelerator).toContain('V');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Validate Site to a renderer menu event', () => {
|
||||||
|
expect(APP_MENU_ACTION_EVENT_MAP.validateSite).toBe('menu:validateSite');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,4 +47,23 @@ describe('tabPersistence', () => {
|
|||||||
|
|
||||||
expect(() => saveTabsForProject(projectId, sampleTabState)).not.toThrow();
|
expect(() => saveTabsForProject(projectId, sampleTabState)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not persist transient tabs', () => {
|
||||||
|
const tabStateWithTransient = {
|
||||||
|
tabs: [
|
||||||
|
{ id: 'documentation', type: 'documentation', isTransient: false },
|
||||||
|
{ id: 'site-validation-report', type: 'site-validation', isTransient: true },
|
||||||
|
],
|
||||||
|
activeTabId: 'site-validation-report',
|
||||||
|
} as unknown as TabState;
|
||||||
|
|
||||||
|
saveTabsForProject(projectId, tabStateWithTransient);
|
||||||
|
|
||||||
|
const loaded = loadTabsForProject(projectId);
|
||||||
|
|
||||||
|
expect(loaded?.tabs).toEqual([
|
||||||
|
{ id: 'documentation', type: 'documentation', isTransient: false },
|
||||||
|
]);
|
||||||
|
expect(loaded?.activeTabId).toBe('documentation');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user