From 64e1fb3d908325c8cf852b811cc0654b252e58b1 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 14:15:57 +0100 Subject: [PATCH] feat: first take at calendar --- package-lock.json | 11 + package.json | 1 + src/main/engine/BlogGenerationEngine.ts | 12 +- src/main/engine/GenerationPostIndexService.ts | 2 +- .../engine/GenerationSitemapFeedService.ts | 42 ++++ src/main/engine/PageRenderer.ts | 13 ++ src/main/engine/assets/calendarRuntime.ts | 190 ++++++++++++++++++ .../engine/templates/partials/head.liquid | 3 + .../engine/templates/partials/menu.liquid | 51 ++++- .../engine/templates/partials/styles.liquid | 15 +- src/main/shared/i18n/locales/de.json | 5 + src/main/shared/i18n/locales/en.json | 5 + src/main/shared/i18n/locales/es.json | 5 + src/main/shared/i18n/locales/fr.json | 5 + src/main/shared/i18n/locales/it.json | 5 + tests/engine/BlogGenerationEngine.test.ts | 57 ++++++ .../GenerationSitemapFeedService.test.ts | 20 ++ tests/engine/PreviewServer.test.ts | 3 + 18 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 src/main/engine/assets/calendarRuntime.ts diff --git a/package-lock.json b/package-lock.json index bbac03b..4e4122a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "simple-git": "^3.31.1", "snowball-stemmers": "^0.6.0", "turndown": "^7.2.2", + "vanilla-calendar-pro": "^3.1.0", "zod": "^4.3.6", "zustand": "^5.0.11" }, @@ -14915,6 +14916,16 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vanilla-calendar-pro": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vanilla-calendar-pro/-/vanilla-calendar-pro-3.1.0.tgz", + "integrity": "sha512-yXDtCaedcKz6i5OOdWGwui0C8MAmjXjj7JzKZyjDlkczSRqnhI8BDGFygqT2K+qL1uY7R2fLYlTlxA6oyFs2yg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://buymeacoffee.com/uvarov" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", diff --git a/package.json b/package.json index df46bc3..1d5a691 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "simple-git": "^3.31.1", "snowball-stemmers": "^0.6.0", "turndown": "^7.2.2", + "vanilla-calendar-pro": "^3.1.0", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 94bd616..f2e62bf 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -15,7 +15,7 @@ import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '.. import type { MenuDocument } from './MenuEngine'; import type { ProjectMetadata } from './MetaEngine'; import { loadPublishedGenerationSets } from './GenerationPostSnapshotService'; -import { buildSitemapAndFeeds, collectSitemapArchiveMetadata } from './GenerationSitemapFeedService'; +import { buildCalendarArchiveData, buildSitemapAndFeeds, collectSitemapArchiveMetadata } from './GenerationSitemapFeedService'; import { buildTargetedValidationPlan, planMissingValidationPaths } from './ValidationApplyPlannerService'; import { compareSitemapToHtml } from './SiteValidationDiffService'; import { @@ -228,6 +228,7 @@ export class BlogGenerationEngine { let rssXml = ''; let atomXml = ''; let feedPosts: PostData[] = []; + let calendarJson = ''; if (includeCore) { onProgress(5, 'Building sitemap XML...'); @@ -252,6 +253,7 @@ export class BlogGenerationEngine { rssXml = sitemapAndFeedResult.rssXml; atomXml = sitemapAndFeedResult.atomXml; feedPosts = sitemapAndFeedResult.feedPosts; + calendarJson = `${JSON.stringify(buildCalendarArchiveData(publishedListPosts), null, 2)}\n`; onProgress(8, 'Building RSS and Atom feeds...'); } else if (includeCategory || includeTag || includeDate) { @@ -275,6 +277,7 @@ export class BlogGenerationEngine { const sitemapPath = path.join(htmlDir, 'sitemap.xml'); const rssPath = path.join(htmlDir, 'rss.xml'); const atomPath = path.join(htmlDir, 'atom.xml'); + const calendarPath = path.join(htmlDir, 'calendar.json'); const estimatedUnitsBySection = estimateGenerationUnitsBySection({ posts: publishedListPosts, @@ -333,6 +336,13 @@ export class BlogGenerationEngine { content: atomXml, }); reportUnitProgress('Atom feed written'); + await writeFileIfHashChanged({ + projectId: options.projectId, + filePath: calendarPath, + relativePath: 'calendar.json', + content: calendarJson, + }); + reportUnitProgress('Calendar data written'); onProgress(15, 'Copying assets...'); await copyPreviewAssets(htmlDir, { diff --git a/src/main/engine/GenerationPostIndexService.ts b/src/main/engine/GenerationPostIndexService.ts index b171aff..856006f 100644 --- a/src/main/engine/GenerationPostIndexService.ts +++ b/src/main/engine/GenerationPostIndexService.ts @@ -120,7 +120,7 @@ export function estimateGenerationUnitsBySection(params: { } return { - core: 4 + rootPages + pageRoutes, + core: 5 + rootPages + pageRoutes, single: posts.length, category: categoryPages, tag: tagPages, diff --git a/src/main/engine/GenerationSitemapFeedService.ts b/src/main/engine/GenerationSitemapFeedService.ts index c310288..8f669ea 100644 --- a/src/main/engine/GenerationSitemapFeedService.ts +++ b/src/main/engine/GenerationSitemapFeedService.ts @@ -44,6 +44,12 @@ export interface SitemapArchiveMetadata { latestPostUpdatedAt: string; } +export interface CalendarArchiveData { + years: Record; + months: Record; + days: Record; +} + function resolvePostCreatedAt(post: { createdAt: Date | string }): Date { if (post.createdAt instanceof Date) { return post.createdAt; @@ -154,6 +160,42 @@ function escapeCdata(value: string): string { return value.replace(/]]>/g, ']]]]>'); } +export function buildCalendarArchiveData(posts: PostData[]): CalendarArchiveData { + const yearCounts = new Map(); + const monthCounts = new Map(); + const dayCounts = new Map(); + + for (const post of posts) { + const createdAt = resolvePostCreatedAt(post); + const year = String(createdAt.getFullYear()); + const month = String(createdAt.getMonth() + 1).padStart(2, '0'); + const day = String(createdAt.getDate()).padStart(2, '0'); + + const monthKey = `${year}-${month}`; + const dayKey = `${monthKey}-${day}`; + + yearCounts.set(year, (yearCounts.get(year) ?? 0) + 1); + monthCounts.set(monthKey, (monthCounts.get(monthKey) ?? 0) + 1); + dayCounts.set(dayKey, (dayCounts.get(dayKey) ?? 0) + 1); + } + + const toObject = (entries: Map): Record => { + const result: Record = {}; + + for (const key of Array.from(entries.keys()).sort()) { + result[key] = entries.get(key) ?? 0; + } + + return result; + }; + + return { + years: toObject(yearCounts), + months: toObject(monthCounts), + days: toObject(dayCounts), + }; +} + export function collectSitemapArchiveMetadata(params: { baseUrl: string; maxPostsPerPage: number; diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index f487248..8cd7881 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -6,6 +6,7 @@ import type { PostData } from './PostEngine'; import type { MenuDocument, MenuItemData } from './MenuEngine'; import { PICO_THEME_NAMES } from '../shared/picoThemes'; import { CODE_ENHANCEMENTS_RUNTIME_JS } from './assets/codeEnhancementsRuntime'; +import { CALENDAR_RUNTIME_JS } from './assets/calendarRuntime'; import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime'; import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n'; @@ -222,6 +223,18 @@ export const PREVIEW_ASSETS: Record = { contentType: 'application/javascript; charset=utf-8', sourceText: TAG_CLOUD_RUNTIME_JS, }, + 'vanilla-calendar.min.css': { + modulePath: 'vanilla-calendar-pro/styles/index.css', + contentType: 'text/css; charset=utf-8', + }, + 'vanilla-calendar.min.js': { + modulePath: 'vanilla-calendar-pro', + contentType: 'application/javascript; charset=utf-8', + }, + 'calendar-runtime.js': { + contentType: 'application/javascript; charset=utf-8', + sourceText: CALENDAR_RUNTIME_JS, + }, }; export const PREVIEW_IMAGE_ASSETS = { diff --git a/src/main/engine/assets/calendarRuntime.ts b/src/main/engine/assets/calendarRuntime.ts new file mode 100644 index 0000000..26e10a6 --- /dev/null +++ b/src/main/engine/assets/calendarRuntime.ts @@ -0,0 +1,190 @@ +export const CALENDAR_RUNTIME_JS = String.raw`(() => { + const button = document.querySelector('[data-blog-calendar-toggle]'); + const panel = document.querySelector('[data-blog-calendar-panel]'); + const closeButton = document.querySelector('[data-blog-calendar-close]'); + const calendarRoot = document.querySelector('[data-blog-calendar-root]'); + const status = document.querySelector('[data-blog-calendar-status]'); + + if (!button || !panel || !calendarRoot || !status) { + return; + } + + const labels = { + loading: panel.getAttribute('data-i18n-loading') || 'Loading calendar…', + error: panel.getAttribute('data-i18n-error') || 'Calendar data could not be loaded.', + }; + + let isInitialized = false; + let years = {}; + let months = {}; + let days = {}; + + function pad2(value) { + return String(value).padStart(2, '0'); + } + + function normalizeCountMap(value) { + if (!value || typeof value !== 'object') { + return {}; + } + + const map = {}; + for (const [key, rawCount] of Object.entries(value)) { + const count = Number(rawCount); + if (!Number.isFinite(count) || count <= 0) { + continue; + } + map[key] = Math.floor(count); + } + + return map; + } + + function navigateTo(pathname) { + if (!pathname) { + return; + } + window.location.assign(pathname); + } + + async function loadCalendarData() { + const response = await fetch('/calendar.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error('calendar.json request failed'); + } + + const parsed = await response.json(); + years = normalizeCountMap(parsed?.years); + months = normalizeCountMap(parsed?.months); + days = normalizeCountMap(parsed?.days); + } + + function getDateFromClickEvent(event) { + if (!(event?.target instanceof Element)) { + return ''; + } + + const dateEl = event.target.closest('[data-vc-date]'); + if (!(dateEl instanceof HTMLElement)) { + return ''; + } + + return dateEl.dataset.vcDate || ''; + } + + async function initializeCalendar() { + if (isInitialized) { + return; + } + + status.textContent = labels.loading; + + try { + await loadCalendarData(); + + const Calendar = window.VanillaCalendarPro?.Calendar; + if (typeof Calendar !== 'function') { + throw new Error('Vanilla Calendar Pro is unavailable'); + } + + const calendar = new Calendar('[data-blog-calendar-root]', { + onCreateDateEls(_self, dateEl) { + const dateKey = dateEl.dataset.vcDate || ''; + const count = Number(days[dateKey] || 0); + const buttonEl = dateEl.querySelector('[data-vc-date-btn]'); + + if (!(buttonEl instanceof HTMLElement)) { + return; + } + + const existing = buttonEl.querySelector('.blog-calendar-post-count'); + if (existing) { + existing.remove(); + } + + if (count <= 0) { + dateEl.removeAttribute('data-blog-calendar-has-posts'); + return; + } + + dateEl.setAttribute('data-blog-calendar-has-posts', 'true'); + const marker = document.createElement('span'); + marker.className = 'blog-calendar-post-count'; + marker.textContent = String(count); + buttonEl.appendChild(marker); + }, + onClickDate(_self, event) { + const dateKey = getDateFromClickEvent(event); + if (!dateKey || !days[dateKey]) { + return; + } + + const [year, month, day] = dateKey.split('-'); + if (!year || !month || !day) { + return; + } + + navigateTo('/' + year + '/' + month + '/' + day + '/'); + }, + onClickMonth(self) { + const selectedYear = Number(self?.context?.selectedYear); + const selectedMonth = Number(self?.context?.selectedMonth); + + if (!Number.isInteger(selectedYear) || !Number.isInteger(selectedMonth)) { + return; + } + + const monthKey = String(selectedYear) + '-' + pad2(selectedMonth + 1); + if (!months[monthKey]) { + return; + } + + navigateTo('/' + String(selectedYear) + '/' + pad2(selectedMonth + 1) + '/'); + }, + onClickYear(self) { + const selectedYear = Number(self?.context?.selectedYear); + + if (!Number.isInteger(selectedYear)) { + return; + } + + const yearKey = String(selectedYear); + if (!years[yearKey]) { + return; + } + + navigateTo('/' + String(selectedYear) + '/'); + }, + }); + + calendar.init(); + status.textContent = ''; + status.setAttribute('hidden', 'hidden'); + isInitialized = true; + } catch { + status.textContent = labels.error; + status.removeAttribute('hidden'); + } + } + + function setPanelOpen(isOpen) { + if (isOpen) { + panel.removeAttribute('hidden'); + void initializeCalendar(); + return; + } + + panel.setAttribute('hidden', 'hidden'); + } + + button.addEventListener('click', () => { + const isHidden = panel.hasAttribute('hidden'); + setPanelOpen(isHidden); + }); + + if (closeButton) { + closeButton.addEventListener('click', () => { + setPanelOpen(false); + }); + } +})();`; diff --git a/src/main/engine/templates/partials/head.liquid b/src/main/engine/templates/partials/head.liquid index 73bde80..fe3d23e 100644 --- a/src/main/engine/templates/partials/head.liquid +++ b/src/main/engine/templates/partials/head.liquid @@ -6,6 +6,7 @@ + {% render 'partials/styles' %} @@ -14,4 +15,6 @@ + + diff --git a/src/main/engine/templates/partials/menu.liquid b/src/main/engine/templates/partials/menu.liquid index c2d64be..0a05ecf 100644 --- a/src/main/engine/templates/partials/menu.liquid +++ b/src/main/engine/templates/partials/menu.liquid @@ -1,5 +1,48 @@ -{% if menu_items and menu_items.size > 0 %} - diff --git a/src/main/engine/templates/partials/styles.liquid b/src/main/engine/templates/partials/styles.liquid index 9c141a1..2b39043 100644 --- a/src/main/engine/templates/partials/styles.liquid +++ b/src/main/engine/templates/partials/styles.liquid @@ -6,7 +6,7 @@ [data-theme='dark'] { --pico-background-color: #13171f; } body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; background: var(--pico-background-color, var(--background-color)); color: var(--pico-color, var(--color)); } main { display: grid; gap: 1rem; } - .blog-menu { border-top: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-bottom: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: .4rem 0; margin: -.15rem 0 .2rem; } + .blog-menu { position: relative; display: flex; align-items: center; justify-content: space-between; gap: .75rem; border-top: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-bottom: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: .4rem 0; margin: -.15rem 0 .2rem; } .blog-menu-list { list-style: none; display: flex; flex-wrap: wrap; gap: .25rem .75rem; margin: 0; padding: 0; } .blog-menu-item { position: relative; } .blog-menu-link { display: inline-flex; align-items: center; color: var(--pico-muted-color, var(--muted-color)); text-decoration: none; font-size: .94rem; line-height: 1.4; padding: .2rem .1rem; } @@ -17,6 +17,19 @@ .blog-menu-submenu .blog-menu-list { flex-direction: column; flex-wrap: nowrap; gap: .2rem; } .blog-menu-item-with-children:hover > .blog-menu-submenu, .blog-menu-item-with-children:focus-within > .blog-menu-submenu { display: block; } + .blog-menu-calendar { position: relative; display: inline-flex; align-items: center; justify-content: center; margin-left: auto; } + .blog-menu-calendar-button { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-card-background-color, var(--card-background-color)); color: var(--pico-muted-color, var(--muted-color)); border-radius: .25rem; cursor: pointer; } + .blog-menu-calendar-button svg { display: block; width: 1rem; height: 1rem; fill: none; stroke: currentColor; } + .blog-menu-calendar-button:hover, + .blog-menu-calendar-button:focus-visible { color: var(--pico-color, var(--color)); border-color: var(--pico-color, var(--color)); } + .blog-calendar-panel { position: absolute; top: calc(100% + .3rem); right: 0; width: min(21.5rem, 92vw); border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-card-background-color, var(--card-background-color)); padding: .55rem; z-index: 30; } + .blog-calendar-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .35rem; } + .blog-calendar-close { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: transparent; color: var(--pico-muted-color, var(--muted-color)); width: 1.8rem; height: 1.8rem; border-radius: .2rem; padding: 0; cursor: pointer; } + .blog-calendar-close:hover, + .blog-calendar-close:focus-visible { color: var(--pico-color, var(--color)); border-color: var(--pico-color, var(--color)); } + .blog-calendar-status { margin: .45rem 0 0; color: var(--pico-muted-color, var(--muted-color)); font-size: .85rem; } + [data-blog-calendar-has-posts='true'] [data-vc-date-btn] { border-color: var(--pico-primary, var(--pico-color, var(--color))); } + .blog-calendar-post-count { display: inline-flex; align-items: center; justify-content: center; min-width: 1.15rem; height: 1.15rem; margin-left: .22rem; border-radius: 999px; font-size: .62rem; line-height: 1; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); color: var(--pico-color, var(--color)); } .post { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: 1rem; background: var(--pico-card-background-color, var(--card-background-color)); min-width: 0; } .post pre { position: relative; overflow-x: auto; max-width: 100%; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-radius: .3rem; margin: .9rem 0; padding: .85rem .9rem; background: var(--pico-code-background-color, rgba(33, 38, 45, .82)); box-sizing: border-box; } .post pre code { display: block; font-size: .88rem; line-height: 1.5; white-space: pre; } diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index 6c6cc7d..50bf564 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -53,6 +53,11 @@ "render.gallery.empty": "Keine verknüpften Bilder gefunden.", "render.tagCloud.empty": "Keine Tags gefunden.", "render.tagCloud.ariaLabel": "Tag-Wolke", + "render.calendar.open": "Kalender öffnen", + "render.calendar.close": "Kalender schließen", + "render.calendar.title": "Archivkalender", + "render.calendar.loading": "Kalender wird geladen …", + "render.calendar.error": "Kalenderdaten konnten nicht geladen werden.", "render.taxonomy.ariaLabel": "Taxonomie", "render.video.youtubeTitle": "YouTube-Video", "render.video.vimeoTitle": "Vimeo-Video", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index df9162d..366a04c 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -53,6 +53,11 @@ "render.gallery.empty": "No linked images found.", "render.tagCloud.empty": "No tags found.", "render.tagCloud.ariaLabel": "Tag cloud", + "render.calendar.open": "Open calendar", + "render.calendar.close": "Close calendar", + "render.calendar.title": "Archive calendar", + "render.calendar.loading": "Loading calendar…", + "render.calendar.error": "Calendar data could not be loaded.", "render.taxonomy.ariaLabel": "Taxonomy", "render.video.youtubeTitle": "YouTube video", "render.video.vimeoTitle": "Vimeo video", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index 1100a6e..700bb74 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -53,6 +53,11 @@ "render.gallery.empty": "No se encontraron imágenes vinculadas.", "render.tagCloud.empty": "No se encontraron etiquetas.", "render.tagCloud.ariaLabel": "Nube de etiquetas", + "render.calendar.open": "Abrir calendario", + "render.calendar.close": "Cerrar calendario", + "render.calendar.title": "Calendario de archivo", + "render.calendar.loading": "Cargando calendario…", + "render.calendar.error": "No se pudieron cargar los datos del calendario.", "render.taxonomy.ariaLabel": "Taxonomía", "render.video.youtubeTitle": "Vídeo de YouTube", "render.video.vimeoTitle": "Vídeo de Vimeo", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index 4092333..a8a0b53 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -53,6 +53,11 @@ "render.gallery.empty": "Aucune image liée trouvée.", "render.tagCloud.empty": "Aucun tag trouvé.", "render.tagCloud.ariaLabel": "Nuage de tags", + "render.calendar.open": "Ouvrir le calendrier", + "render.calendar.close": "Fermer le calendrier", + "render.calendar.title": "Calendrier des archives", + "render.calendar.loading": "Chargement du calendrier…", + "render.calendar.error": "Impossible de charger les données du calendrier.", "render.taxonomy.ariaLabel": "Taxonomie", "render.video.youtubeTitle": "Vidéo YouTube", "render.video.vimeoTitle": "Vidéo Vimeo", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index b964426..04452ff 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -53,6 +53,11 @@ "render.gallery.empty": "Nessuna immagine collegata trovata.", "render.tagCloud.empty": "Nessun tag trovato.", "render.tagCloud.ariaLabel": "Nuvola di tag", + "render.calendar.open": "Apri calendario", + "render.calendar.close": "Chiudi calendario", + "render.calendar.title": "Calendario archivio", + "render.calendar.loading": "Caricamento calendario…", + "render.calendar.error": "Impossibile caricare i dati del calendario.", "render.taxonomy.ariaLabel": "Tassonomia", "render.video.youtubeTitle": "Video YouTube", "render.video.vimeoTitle": "Video Vimeo", diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 6f55eff..bf5ead6 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -336,6 +336,9 @@ describe('BlogGenerationEngine', () => { expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.css'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.js'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'assets', 'tag-cloud.js'))).toBe(true); + expect(await fileExists(path.join(tempDir, 'html', 'assets', 'vanilla-calendar.min.css'))).toBe(true); + expect(await fileExists(path.join(tempDir, 'html', 'assets', 'vanilla-calendar.min.js'))).toBe(true); + expect(await fileExists(path.join(tempDir, 'html', 'assets', 'calendar-runtime.js'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'images', 'prev.png'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'images', 'next.png'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'images', 'close.png'))).toBe(true); @@ -345,6 +348,60 @@ describe('BlogGenerationEngine', () => { expect(picoContent.length).toBeGreaterThan(0); }); + it('writes calendar.json and wires calendar UI in generated html', async () => { + const posts = [ + makePost({ + id: '1', + slug: 'one', + title: 'One', + categories: ['news'], + createdAt: new Date('2025-03-15T10:00:00Z'), + }), + makePost({ + id: '2', + slug: 'two', + title: 'Two', + categories: ['news'], + createdAt: new Date('2025-03-15T12:00:00Z'), + }), + makePost({ + id: '3', + slug: 'three', + title: 'Three', + categories: ['news'], + createdAt: new Date('2025-04-01T10:00:00Z'), + }), + ]; + + await generate(posts, { + menu: { + items: [ + { id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] }, + ], + }, + }); + + const calendarJsonRaw = await readFile(path.join(tempDir, 'html', 'calendar.json'), 'utf-8'); + const calendarJson = JSON.parse(calendarJsonRaw) as { + years: Record; + months: Record; + days: Record; + }; + + expect(calendarJson.years['2025']).toBe(3); + expect(calendarJson.months['2025-03']).toBe(2); + expect(calendarJson.months['2025-04']).toBe(1); + expect(calendarJson.days['2025-03-15']).toBe(2); + expect(calendarJson.days['2025-04-01']).toBe(1); + + const indexHtml = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8'); + expect(indexHtml).toContain('class="blog-menu-calendar-button"'); + expect(indexHtml).toContain('id="blog-calendar"'); + expect(indexHtml).toContain('href="/assets/vanilla-calendar.min.css"'); + expect(indexHtml).toContain('src="/assets/vanilla-calendar.min.js"'); + expect(indexHtml).toContain('src="/assets/calendar-runtime.js"'); + }); + it('generates root index.html for published posts', async () => { const posts = [ makePost({ id: '1', slug: 'first', title: 'First Post', createdAt: new Date('2025-01-15T10:00:00Z') }), diff --git a/tests/engine/GenerationSitemapFeedService.test.ts b/tests/engine/GenerationSitemapFeedService.test.ts index d993041..b6c802d 100644 --- a/tests/engine/GenerationSitemapFeedService.test.ts +++ b/tests/engine/GenerationSitemapFeedService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { PostData } from '../../src/main/engine/PostEngine'; import { + buildCalendarArchiveData, buildSitemapAndFeeds, type GenerationPostIndexLike, } from '../../src/main/engine/GenerationSitemapFeedService'; @@ -71,6 +72,25 @@ function buildIndex(posts: PostData[]): GenerationPostIndexLike { } 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({ diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index c449c5e..7119567 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -305,11 +305,14 @@ describe('PreviewServer', () => { expect(rootHtml).toContain('href="/assets/pico.min.css"'); expect(rootHtml).toContain('href="/assets/lightbox.min.css"'); expect(rootHtml).toContain('href="/assets/highlight.min.css"'); + expect(rootHtml).toContain('href="/assets/vanilla-calendar.min.css"'); expect(rootHtml).toContain('src="/assets/lightbox.min.js"'); expect(rootHtml).toContain('src="/assets/highlight.min.js"'); expect(rootHtml).toContain('src="/assets/code-enhancements.js"'); expect(rootHtml).toContain('src="/assets/d3.layout.cloud.js"'); expect(rootHtml).toContain('src="/assets/tag-cloud.js"'); + expect(rootHtml).toContain('src="/assets/vanilla-calendar.min.js"'); + expect(rootHtml).toContain('src="/assets/calendar-runtime.js"'); expect(rootHtml).not.toContain('function parseWords('); expect(rootHtml).not.toContain('cdn.jsdelivr.net');