import { describe, expect, it } from 'vitest'; import type { PostData } from '../../src/main/engine/PostEngine'; import { buildCalendarArchiveData, buildSitemapAndFeeds, type GenerationPostIndexLike, } from '../../src/main/engine/GenerationSitemapFeedService'; function makePost(overrides: Partial = {}): PostData { const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z'); const updatedAt = overrides.updatedAt ?? createdAt; const title = overrides.title ?? 'Title'; return { id: overrides.id ?? 'post-1', projectId: overrides.projectId ?? 'default', title, slug: overrides.slug ?? 'title', excerpt: overrides.excerpt, content: overrides.content ?? `# ${title}\n\nBody`, status: overrides.status ?? 'published', author: overrides.author, language: overrides.language, createdAt, updatedAt, publishedAt: overrides.publishedAt, tags: overrides.tags ?? [], categories: overrides.categories ?? [], }; } function buildIndex(posts: PostData[]): GenerationPostIndexLike { const postsByCategory = new Map(); const postsByTag = new Map(); const postsByYear = new Map(); const postsByYearMonth = new Map(); const postsByYearMonthDay = new Map(); for (const post of posts) { const categories = Array.isArray(post.categories) ? post.categories : []; for (const category of categories) { const existing = postsByCategory.get(category) ?? []; existing.push(post); postsByCategory.set(category, existing); } const tags = Array.isArray(post.tags) ? post.tags : []; for (const tag of tags) { const existing = postsByTag.get(tag) ?? []; existing.push(post); postsByTag.set(tag, existing); } const createdAt = post.createdAt; const year = createdAt.getFullYear(); const month = String(createdAt.getMonth() + 1).padStart(2, '0'); const day = String(createdAt.getDate()).padStart(2, '0'); const yearMonth = `${year}/${month}`; const yearMonthDay = `${year}/${month}/${day}`; postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]); postsByYearMonth.set(yearMonth, [...(postsByYearMonth.get(yearMonth) ?? []), post]); postsByYearMonthDay.set(yearMonthDay, [...(postsByYearMonthDay.get(yearMonthDay) ?? []), post]); } return { postsByCategory, postsByTag, postsByYear, postsByYearMonth, postsByYearMonthDay, }; } describe('GenerationSitemapFeedService', () => { it('builds calendar archive data with year/month/day post counts', () => { const publishedPosts = [ makePost({ id: '1', slug: 'a', createdAt: new Date('2025-01-15T10:00:00.000Z') }), makePost({ id: '2', slug: 'b', createdAt: new Date('2025-01-15T12:00:00.000Z') }), makePost({ id: '3', slug: 'c', createdAt: new Date('2025-02-01T08:00:00.000Z') }), makePost({ id: '4', slug: 'd', createdAt: new Date('2026-01-01T08:00:00.000Z') }), ]; const result = buildCalendarArchiveData(publishedPosts); expect(result.years['2025']).toBe(3); expect(result.years['2026']).toBe(1); expect(result.months['2025-01']).toBe(2); expect(result.months['2025-02']).toBe(1); expect(result.days['2025-01-15']).toBe(2); expect(result.days['2025-02-01']).toBe(1); expect(result.days['2026-01-01']).toBe(1); }); it('builds canonical sitemap urls and paginated archive routes', () => { const publishedPosts = [ makePost({ id: '1', slug: 'news-1', createdAt: new Date('2025-01-15T10:00:00.000Z'), categories: ['news'], tags: ['tag-a'], }), makePost({ id: '2', slug: 'news-2', createdAt: new Date('2025-01-14T10:00:00.000Z'), categories: ['news'], tags: ['tag-a'], }), makePost({ id: '3', slug: 'about', createdAt: new Date('2025-01-13T10:00:00.000Z'), categories: ['page'], }), ]; const publishedListPosts = publishedPosts.filter((post) => !(post.categories || []).includes('page')); const result = buildSitemapAndFeeds({ baseUrl: 'https://example.com', projectName: 'Test Blog', projectDescription: 'Desc', maxPostsPerPage: 1, publishedPosts, publishedListPosts, postIndex: buildIndex(publishedListPosts), includeFeeds: true, }); expect(result.sitemapXml).toContain('https://example.com/'); expect(result.sitemapXml).toContain('https://example.com/page/2/'); expect(result.sitemapXml).toContain('https://example.com/2025/01/15/news-1/'); expect(result.sitemapXml).toContain('https://example.com/category/news/page/2/'); expect(result.sitemapXml).toContain('https://example.com/tag/tag-a/page/2/'); expect(result.sitemapXml).toContain('https://example.com/about/'); expect(result.rssXml).toContain(''); }); it('can skip feed xml generation for sitemap-only flows', () => { const publishedPosts = [makePost({ id: '1', slug: 'post-1', categories: ['news'], tags: ['t1'] })]; const result = buildSitemapAndFeeds({ baseUrl: 'https://example.com', projectName: 'Test Blog', maxPostsPerPage: 10, publishedPosts, publishedListPosts: publishedPosts, postIndex: buildIndex(publishedPosts), includeFeeds: false, }); expect(result.sitemapXml).toContain(''); expect(result.rssXml).toBe(''); expect(result.atomXml).toBe(''); }); it('includes per-post language in RSS dc:language and Atom xml:lang', () => { const publishedPosts = [ makePost({ id: '1', slug: 'post-en', title: 'English', language: 'en' }), makePost({ id: '2', slug: 'post-de', title: 'German', language: 'de' }), makePost({ id: '3', slug: 'post-no-lang', title: 'Default' }), ]; const result = buildSitemapAndFeeds({ baseUrl: 'https://example.com', projectName: 'Test Blog', maxPostsPerPage: 10, publishedPosts, publishedListPosts: publishedPosts, postIndex: buildIndex(publishedPosts), includeFeeds: true, }); // RSS should have dc:language per item expect(result.rssXml).toContain('xmlns:dc='); expect(result.rssXml).toContain('en'); expect(result.rssXml).toContain('de'); // Atom should have xml:lang on entries with language expect(result.atomXml).toContain('xml:lang="en"'); expect(result.atomXml).toContain('xml:lang="de"'); }); });