fix: make sitemap work properly

This commit is contained in:
2026-02-19 09:40:12 +01:00
parent 623aab62c3
commit b410736a67
7 changed files with 389 additions and 81 deletions

View File

@@ -18,6 +18,7 @@ export interface ProjectMetadata {
name: string; name: string;
description?: string; description?: string;
dataPath?: string; // Custom path for project data dataPath?: string; // Custom path for project data
publicUrl?: string; // Public base URL for the published blog (e.g., https://example.com)
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es') mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
defaultAuthor?: string; // Default author for new posts and media defaultAuthor?: string; // Default author for new posts and media
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
@@ -48,10 +49,21 @@ function sanitizeMaxPostsPerPage(value: unknown): number | undefined {
return rounded; return rounded;
} }
function sanitizePublicUrl(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
const trimmed = String(value).trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage); const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
return { return {
...metadata, ...metadata,
publicUrl,
maxPostsPerPage, maxPostsPerPage,
}; };
} }
@@ -173,6 +185,7 @@ export class MetaEngine extends EventEmitter {
name: normalizedUpdates.name || '', name: normalizedUpdates.name || '',
description: normalizedUpdates.description, description: normalizedUpdates.description,
dataPath: normalizedUpdates.dataPath, dataPath: normalizedUpdates.dataPath,
publicUrl: normalizedUpdates.publicUrl,
mainLanguage: normalizedUpdates.mainLanguage, mainLanguage: normalizedUpdates.mainLanguage,
defaultAuthor: normalizedUpdates.defaultAuthor, defaultAuthor: normalizedUpdates.defaultAuthor,
maxPostsPerPage: normalizedUpdates.maxPostsPerPage, maxPostsPerPage: normalizedUpdates.maxPostsPerPage,

View File

@@ -111,6 +111,25 @@ function buildSitemapUrl(
].join('\n'); ].join('\n');
} }
function resolvePublicBaseUrl(publicUrl?: string): string | null {
const trimmed = (publicUrl || '').trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`;
} catch {
return null;
}
}
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
// ============ Git Handlers ============ // ============ Git Handlers ============
@@ -749,6 +768,7 @@ export function registerIpcHandlers(): void {
return { return {
name: metadata.name || undefined, name: metadata.name || undefined,
description: metadata.description || undefined, description: metadata.description || undefined,
publicUrl: metadata.publicUrl || undefined,
mainLanguage: metadata.mainLanguage || undefined, mainLanguage: metadata.mainLanguage || undefined,
}; };
} catch { } catch {
@@ -847,7 +867,7 @@ export function registerIpcHandlers(): void {
return engine.getProjectMetadata(); return engine.getProjectMetadata();
}); });
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => { safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => {
const engine = getMetaEngine(); const engine = getMetaEngine();
await engine.updateProjectMetadata(updates); await engine.updateProjectMetadata(updates);
return engine.getProjectMetadata(); return engine.getProjectMetadata();
@@ -1306,12 +1326,33 @@ export function registerIpcHandlers(): void {
safeHandle('blog:generateSitemap', async () => { safeHandle('blog:generateSitemap', async () => {
const projectEngine = getProjectEngine(); const projectEngine = getProjectEngine();
const postEngine = getPostEngine();
const metaEngine = getMetaEngine();
const project = await projectEngine.getActiveProject(); const project = await projectEngine.getActiveProject();
if (!project) { if (!project) {
throw new Error('No active project'); throw new Error('No active project');
} }
const dataDir = projectEngine.getDataDir(project.id, project.dataPath); const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
postEngine.setProjectContext(project.id, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
if (!metaEngine.isInitialized()) {
await metaEngine.syncOnStartup();
}
const metadata = await metaEngine.getProjectMetadata();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
await dialog.showMessageBox({
type: 'warning',
title: 'Public URL Required',
message: 'Sitemap generation requires a public URL.',
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
});
throw new Error('Project public URL is not configured');
}
const taskId = `sitemap-generate-${Date.now()}`; const taskId = `sitemap-generate-${Date.now()}`;
return taskManager.runTask({ return taskManager.runTask({
@@ -1320,23 +1361,28 @@ export function registerIpcHandlers(): void {
execute: async (onProgress) => { execute: async (onProgress) => {
onProgress(0, 'Loading posts...'); onProgress(0, 'Loading posts...');
const db = getDatabase().getLocal(); const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' });
const { posts: postsTable } = await import('../database/schema'); const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
const { eq: eqOp, desc: descOp } = await import('drizzle-orm');
const dbPosts = await db const draftPublishedSnapshots = await Promise.all(
.select() draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
.from(postsTable) );
.where(eqOp(postsTable.projectId, project.id))
.orderBy(descOp(postsTable.createdAt))
.all();
// Only include published posts (not drafts or archived) in sitemap const publishedPostById = new Map<string, PostData>();
const publishedPosts = dbPosts.filter(p => p.status === 'published'); for (const post of publishedCandidates) {
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());
onProgress(10, `Found ${publishedPosts.length} published posts`); onProgress(10, `Found ${publishedPosts.length} published posts`);
const baseUrl = 'http://127.0.0.1:4123';
const now = new Date().toISOString(); const now = new Date().toISOString();
// Collect all unique tags, categories, and year/month/day archives // Collect all unique tags, categories, and year/month/day archives
@@ -1349,9 +1395,8 @@ export function registerIpcHandlers(): void {
const postUrls: Array<{ loc: string; lastmod: string }> = []; const postUrls: Array<{ loc: string; lastmod: string }> = [];
for (const post of publishedPosts) { for (const post of publishedPosts) {
// Parse tags and categories const tags = post.tags || [];
const tags: string[] = JSON.parse(post.tags || '[]'); const categories = post.categories || [];
const categories: string[] = JSON.parse(post.categories || '[]');
for (const tag of tags) allTags.add(tag); for (const tag of tags) allTags.add(tag);
for (const cat of categories) allCategories.add(cat); for (const cat of categories) allCategories.add(cat);
@@ -1360,9 +1405,7 @@ export function registerIpcHandlers(): void {
const createdAt = resolvePostCreatedAt(post); const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const postUrl = `${baseUrl}${canonicalPath}`; const postUrl = `${baseUrl}${canonicalPath}`;
const updatedAt = post.updatedAt instanceof Date const updatedAt = post.updatedAt;
? post.updatedAt
: new Date(post.updatedAt as unknown as Date | string | number);
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() }); postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
// Track archives // Track archives

View File

@@ -38,6 +38,7 @@ export interface ProjectMetadata {
name: string; name: string;
description?: string; description?: string;
dataPath?: string; dataPath?: string;
publicUrl?: string;
mainLanguage?: string; mainLanguage?: string;
defaultAuthor?: string; defaultAuthor?: string;
maxPostsPerPage?: number; maxPostsPerPage?: number;
@@ -510,7 +511,7 @@ export interface ElectronAPI {
showItemInFolder: (itemPath: string) => Promise<void>; showItemInFolder: (itemPath: string) => Promise<void>;
selectFolder: (title?: string) => Promise<string | null>; selectFolder: (title?: string) => Promise<string | null>;
getDefaultProjectPath: (projectId: string) => Promise<string>; getDefaultProjectPath: (projectId: string) => Promise<string>;
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>; readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>;
setPreviewPostTarget: (postId: string | null) => Promise<void>; setPreviewPostTarget: (postId: string | null) => Promise<void>;
triggerMenuAction: (action: string) => Promise<void>; triggerMenuAction: (action: string) => Promise<void>;
}; };
@@ -524,7 +525,7 @@ export interface ElectronAPI {
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
getProjectMetadata: () => Promise<ProjectMetadata | null>; getProjectMetadata: () => Promise<ProjectMetadata | null>;
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise<ProjectMetadata | null>; updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise<ProjectMetadata | null>;
}; };
tags: { tags: {
getAll: () => Promise<TagData[]>; getAll: () => Promise<TagData[]>;

View File

@@ -107,6 +107,7 @@ export const SettingsView: React.FC = () => {
const [projectName, setProjectName] = useState(''); const [projectName, setProjectName] = useState('');
const [projectDescription, setProjectDescription] = useState(''); const [projectDescription, setProjectDescription] = useState('');
const [projectDataPath, setProjectDataPath] = useState(''); const [projectDataPath, setProjectDataPath] = useState('');
const [projectPublicUrl, setProjectPublicUrl] = useState('');
const [defaultProjectPath, setDefaultProjectPath] = useState(''); const [defaultProjectPath, setDefaultProjectPath] = useState('');
const [projectMainLanguage, setProjectMainLanguage] = useState('en'); const [projectMainLanguage, setProjectMainLanguage] = useState('en');
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState(''); const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
@@ -145,8 +146,13 @@ export const SettingsView: React.FC = () => {
setDefaultProjectPath(path); setDefaultProjectPath(path);
}); });
// Load project metadata (includes mainLanguage and defaultAuthor) // Load project metadata (includes public URL, language, and default author)
window.electronAPI?.meta.getProjectMetadata().then(metadata => { window.electronAPI?.meta.getProjectMetadata().then(metadata => {
if (metadata?.publicUrl) {
setProjectPublicUrl(metadata.publicUrl);
} else {
setProjectPublicUrl('');
}
if (metadata?.mainLanguage) { if (metadata?.mainLanguage) {
setProjectMainLanguage(metadata.mainLanguage); setProjectMainLanguage(metadata.mainLanguage);
} }
@@ -256,6 +262,7 @@ export const SettingsView: React.FC = () => {
name: projectName.trim() || activeProject.name, name: projectName.trim() || activeProject.name,
description: projectDescription.trim(), description: projectDescription.trim(),
dataPath: projectDataPath.trim() || undefined, dataPath: projectDataPath.trim() || undefined,
publicUrl: projectPublicUrl.trim() || undefined,
mainLanguage: projectMainLanguage, mainLanguage: projectMainLanguage,
defaultAuthor: projectDefaultAuthor.trim() || undefined, defaultAuthor: projectDefaultAuthor.trim() || undefined,
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
@@ -280,7 +287,7 @@ export const SettingsView: React.FC = () => {
}; };
// Keywords for each section for search filtering // Keywords for each section for search filtering
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page']; const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page'];
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
@@ -346,6 +353,20 @@ export const SettingsView: React.FC = () => {
</div> </div>
</SettingRow> </SettingRow>
<SettingRow
id="project-public-url"
label="Public URL"
description="The public base URL of your published blog (used for sitemap generation)."
>
<input
id="project-public-url"
type="url"
placeholder="https://example.com"
value={projectPublicUrl}
onChange={(e) => setProjectPublicUrl(e.target.value)}
/>
</SettingRow>
<SettingRow <SettingRow
id="project-language" id="project-language"
label="Main Language" label="Main Language"

View File

@@ -590,6 +590,30 @@ describe('MetaEngine', () => {
expect(metadata?.maxPostsPerPage).toBe(42); expect(metadata?.maxPostsPerPage).toBe(42);
}); });
it('should set and get publicUrl in project metadata', async () => {
await metaEngine.setProjectMetadata({
name: 'My Blog',
publicUrl: 'https://example.com/blog',
});
const metadata = await metaEngine.getProjectMetadata();
expect(metadata?.publicUrl).toBe('https://example.com/blog');
});
it('should persist publicUrl to filesystem', async () => {
await metaEngine.setProjectMetadata({
name: 'Test Project',
publicUrl: 'https://example.com',
});
const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`);
const content = mockFiles.get(projectPath);
const parsed = JSON.parse(content!);
expect(parsed.publicUrl).toBe('https://example.com');
});
it('should sanitize invalid maxPostsPerPage values from filesystem', async () => { it('should sanitize invalid maxPostsPerPage values from filesystem', async () => {
const metaDir = metaEngine.getMetaDir(); const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`); const projectPath = normalizePath(`${metaDir}/project.json`);

View File

@@ -30,6 +30,7 @@ vi.mock('electron', () => ({
dialog: { dialog: {
showOpenDialog: vi.fn(), showOpenDialog: vi.fn(),
showSaveDialog: vi.fn(), showSaveDialog: vi.fn(),
showMessageBox: vi.fn(),
}, },
shell: { shell: {
openPath: vi.fn(), openPath: vi.fn(),
@@ -52,6 +53,7 @@ const mockPostEngine = {
publishPost: vi.fn(), publishPost: vi.fn(),
discardChanges: vi.fn(), discardChanges: vi.fn(),
hasPublishedVersion: vi.fn(), hasPublishedVersion: vi.fn(),
getPublishedVersion: vi.fn(),
isSlugAvailable: vi.fn(), isSlugAvailable: vi.fn(),
generateUniqueSlug: vi.fn(), generateUniqueSlug: vi.fn(),
rebuildDatabaseFromFiles: vi.fn(), rebuildDatabaseFromFiles: vi.fn(),
@@ -1448,9 +1450,13 @@ describe('IPC Handlers', () => {
}); });
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
mockMetaEngine.getProjectMetadata.mockResolvedValue({
name: 'Test Project',
publicUrl: 'https://blog.example.com',
});
// Mock database query to return posts // Mock post engine to return published posts and drafts
const mockDbPosts = [ const mockPublishedPosts = [
{ {
id: 'post-1', id: 'post-1',
projectId: 'test-project', projectId: 'test-project',
@@ -1458,8 +1464,8 @@ describe('IPC Handlers', () => {
status: 'published', status: 'published',
createdAt: new Date('2024-01-15T10:00:00Z'), createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-20T15:00:00Z'), updatedAt: new Date('2024-01-20T15:00:00Z'),
tags: '["tag1","tag2"]', tags: ['tag1', 'tag2'],
categories: '["category1"]', categories: ['category1'],
}, },
{ {
id: 'post-2', id: 'post-2',
@@ -1468,9 +1474,12 @@ describe('IPC Handlers', () => {
status: 'published', status: 'published',
createdAt: new Date('2024-02-10T12:00:00Z'), createdAt: new Date('2024-02-10T12:00:00Z'),
updatedAt: new Date('2024-02-12T09:00:00Z'), updatedAt: new Date('2024-02-12T09:00:00Z'),
tags: '["tag2","tag3"]', tags: ['tag2', 'tag3'],
categories: '["category2"]', categories: ['category2'],
}, },
];
const mockDraftPosts = [
{ {
id: 'post-3', id: 'post-3',
projectId: 'test-project', projectId: 'test-project',
@@ -1478,24 +1487,21 @@ describe('IPC Handlers', () => {
status: 'draft', status: 'draft',
createdAt: new Date('2024-03-01T08:00:00Z'), createdAt: new Date('2024-03-01T08:00:00Z'),
updatedAt: new Date('2024-03-01T08:00:00Z'), updatedAt: new Date('2024-03-01T08:00:00Z'),
tags: '[]', tags: [],
categories: '[]', categories: [],
}, },
]; ];
const mockSelect = { mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
from: vi.fn(() => ({ if (filter.status === 'published') {
where: vi.fn(() => ({ return mockPublishedPosts;
orderBy: vi.fn(() => ({ }
all: vi.fn().mockResolvedValue(mockDbPosts), if (filter.status === 'draft') {
})), return mockDraftPosts;
})), }
})), return [];
};
mockDatabase.getLocal.mockReturnValue({
select: vi.fn(() => mockSelect),
}); });
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
// Mock fs.writeFile // Mock fs.writeFile
const { writeFile, mkdir } = await import('fs/promises'); const { writeFile, mkdir } = await import('fs/promises');
@@ -1553,8 +1559,12 @@ describe('IPC Handlers', () => {
}); });
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
mockMetaEngine.getProjectMetadata.mockResolvedValue({
name: 'Test Project',
publicUrl: 'https://blog.example.com',
});
const mockDbPosts = [ const mockPublishedPosts = [
{ {
id: 'post-1', id: 'post-1',
projectId: 'test-project', projectId: 'test-project',
@@ -1562,9 +1572,12 @@ describe('IPC Handlers', () => {
status: 'published', status: 'published',
createdAt: new Date('2024-01-15T10:00:00Z'), createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-20T15:00:00Z'), updatedAt: new Date('2024-01-20T15:00:00Z'),
tags: '[]', tags: [],
categories: '[]', categories: [],
}, },
];
const mockDraftPosts = [
{ {
id: 'post-2', id: 'post-2',
projectId: 'test-project', projectId: 'test-project',
@@ -1572,9 +1585,12 @@ describe('IPC Handlers', () => {
status: 'draft', status: 'draft',
createdAt: new Date('2024-02-10T12:00:00Z'), createdAt: new Date('2024-02-10T12:00:00Z'),
updatedAt: new Date('2024-02-12T09:00:00Z'), updatedAt: new Date('2024-02-12T09:00:00Z'),
tags: '[]', tags: [],
categories: '[]', categories: [],
}, },
];
const mockArchivedPosts = [
{ {
id: 'post-3', id: 'post-3',
projectId: 'test-project', projectId: 'test-project',
@@ -1582,24 +1598,24 @@ describe('IPC Handlers', () => {
status: 'archived', status: 'archived',
createdAt: new Date('2024-03-01T08:00:00Z'), createdAt: new Date('2024-03-01T08:00:00Z'),
updatedAt: new Date('2024-03-01T08:00:00Z'), updatedAt: new Date('2024-03-01T08:00:00Z'),
tags: '[]', tags: [],
categories: '[]', categories: [],
}, },
]; ];
const mockSelect = { mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
from: vi.fn(() => ({ if (filter.status === 'published') {
where: vi.fn(() => ({ return mockPublishedPosts;
orderBy: vi.fn(() => ({ }
all: vi.fn().mockResolvedValue(mockDbPosts), if (filter.status === 'draft') {
})), return mockDraftPosts;
})), }
})), if (filter.status === 'archived') {
}; return mockArchivedPosts;
}
mockDatabase.getLocal.mockReturnValue({ return [];
select: vi.fn(() => mockSelect),
}); });
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
const { writeFile, mkdir } = await import('fs/promises'); const { writeFile, mkdir } = await import('fs/promises');
vi.mocked(mkdir).mockResolvedValue(undefined); vi.mocked(mkdir).mockResolvedValue(undefined);
@@ -1624,6 +1640,105 @@ describe('IPC Handlers', () => {
expect(sitemapXml).not.toContain('archived-post'); expect(sitemapXml).not.toContain('archived-post');
}); });
it('should include published snapshot for drafts with a former published version', 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',
});
const publishedPost = {
id: 'post-published',
projectId: 'test-project',
slug: 'published-post',
status: 'published',
createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-20T15:00:00Z'),
tags: [],
categories: [],
};
const neverPublishedDraft = {
id: 'post-draft-new',
projectId: 'test-project',
slug: 'draft-no-published-version',
status: 'draft',
createdAt: new Date('2024-02-10T12:00:00Z'),
updatedAt: new Date('2024-02-12T09:00:00Z'),
tags: [],
categories: [],
};
const draftWithPublishedVersion = {
id: 'post-draft-with-published',
projectId: 'test-project',
slug: 'draft-current-slug',
status: 'draft',
createdAt: new Date('2024-03-01T08:00:00Z'),
updatedAt: new Date('2024-03-03T08:00:00Z'),
tags: [],
categories: [],
};
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
if (filter.status === 'published') {
return [publishedPost];
}
if (filter.status === 'draft') {
return [neverPublishedDraft, draftWithPublishedVersion];
}
return [];
});
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
if (id !== 'post-draft-with-published') {
return null;
}
return {
id,
projectId: 'test-project',
slug: 'published-snapshot-slug',
status: 'published',
createdAt: new Date('2023-10-05T07:00:00Z'),
updatedAt: new Date('2023-10-20T09:00:00Z'),
tags: [],
categories: [],
};
});
const { writeFile, mkdir } = await import('fs/promises');
vi.mocked(mkdir).mockResolvedValue(undefined);
vi.mocked(writeFile).mockResolvedValue(undefined);
mockTaskManager.runTask.mockImplementation(async (task: any) => {
const onProgress = vi.fn();
return await task.execute(onProgress);
});
const result = await invokeHandler('blog:generateSitemap');
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'published' });
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'draft' });
expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-new');
expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-with-published');
expect(result.postCount).toBe(2);
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
const sitemapXml = writeFileCall[1] as string;
expect(sitemapXml).toContain('published-post');
expect(sitemapXml).toContain('published-snapshot-slug');
expect(sitemapXml).not.toContain('draft-no-published-version');
expect(sitemapXml).not.toContain('draft-current-slug');
});
it('should use canonical path helpers for post URLs', async () => { it('should use canonical path helpers for post URLs', async () => {
const mockProject = createMockProject({ const mockProject = createMockProject({
id: 'test-project', id: 'test-project',
@@ -1631,8 +1746,12 @@ describe('IPC Handlers', () => {
}); });
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
mockMetaEngine.getProjectMetadata.mockResolvedValue({
name: 'Test Project',
publicUrl: 'https://blog.example.com',
});
const mockDbPosts = [ const mockPublishedPosts = [
{ {
id: 'post-1', id: 'post-1',
projectId: 'test-project', projectId: 'test-project',
@@ -1640,24 +1759,20 @@ describe('IPC Handlers', () => {
status: 'published', status: 'published',
createdAt: new Date('2024-03-25T10:00:00Z'), createdAt: new Date('2024-03-25T10:00:00Z'),
updatedAt: new Date('2024-03-26T15:00:00Z'), updatedAt: new Date('2024-03-26T15:00:00Z'),
tags: '[]', tags: [],
categories: '[]', categories: [],
}, },
]; ];
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
const mockSelect = { if (filter.status === 'published') {
from: vi.fn(() => ({ return mockPublishedPosts;
where: vi.fn(() => ({ }
orderBy: vi.fn(() => ({ if (filter.status === 'draft') {
all: vi.fn().mockResolvedValue(mockDbPosts), return [];
})), }
})), return [];
})),
};
mockDatabase.getLocal.mockReturnValue({
select: vi.fn(() => mockSelect),
}); });
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
const { writeFile, mkdir } = await import('fs/promises'); const { writeFile, mkdir } = await import('fs/promises');
vi.mocked(mkdir).mockResolvedValue(undefined); vi.mocked(mkdir).mockResolvedValue(undefined);
@@ -1674,7 +1789,83 @@ describe('IPC Handlers', () => {
const sitemapXml = writeFileCall[1] as string; const sitemapXml = writeFileCall[1] as string;
// Verify canonical URL format: /YYYY/MM/DD/slug // Verify canonical URL format: /YYYY/MM/DD/slug
expect(sitemapXml).toContain('http://127.0.0.1:4123/2024/03/25/my-test-post'); expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/my-test-post');
});
it('should show setup dialog and abort when project public URL is missing', 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',
});
const { dialog } = await import('electron');
await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('Project public URL is not configured');
expect(dialog.showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
title: 'Public URL Required',
}),
);
expect(mockTaskManager.runTask).not.toHaveBeenCalled();
});
it('should use project public URL from metadata as sitemap base URL', 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',
slug: 'public-url-test-post',
status: 'published',
createdAt: new Date('2024-03-25T10:00:00Z'),
updatedAt: new Date('2024-03-26T15:00:00Z'),
tags: [],
categories: [],
},
];
}
if (filter.status === 'draft') {
return [];
}
return [];
});
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
const { writeFile, mkdir } = await import('fs/promises');
vi.mocked(mkdir).mockResolvedValue(undefined);
vi.mocked(writeFile).mockResolvedValue(undefined);
mockTaskManager.runTask.mockImplementation(async (task: any) => {
const onProgress = vi.fn();
return await task.execute(onProgress);
});
await invokeHandler('blog:generateSitemap');
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
const sitemapXml = writeFileCall[1] as string;
expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/public-url-test-post');
expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post');
}); });
}); });
}); });

View File

@@ -43,8 +43,8 @@ describe('SettingsView Diff Preferences', () => {
meta: { meta: {
...(window as any).electronAPI?.meta, ...(window as any).electronAPI?.meta,
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']), getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75 }), getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75, publicUrl: 'https://example.com' }),
updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12 }), updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12, publicUrl: 'https://example.com' }),
}, },
chat: { chat: {
...(window as any).electronAPI?.chat, ...(window as any).electronAPI?.chat,
@@ -92,4 +92,19 @@ describe('SettingsView Diff Preferences', () => {
expect.objectContaining({ maxPostsPerPage: 75 }) expect.objectContaining({ maxPostsPerPage: 75 })
); );
}); });
it('includes project public URL in metadata save payload', async () => {
render(<SettingsView />);
await screen.findByDisplayValue('https://example.com');
const saveButton = screen.getByRole('button', { name: /save project settings/i });
fireEvent.click(saveButton);
await new Promise((resolve) => setTimeout(resolve, 0));
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
expect.objectContaining({ publicUrl: 'https://example.com' })
);
});
}); });