feat: first take at calendar

This commit is contained in:
2026-02-22 14:15:57 +01:00
parent a29143d6dd
commit 64e1fb3d90
18 changed files with 438 additions and 7 deletions

View File

@@ -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, {

View File

@@ -120,7 +120,7 @@ export function estimateGenerationUnitsBySection(params: {
}
return {
core: 4 + rootPages + pageRoutes,
core: 5 + rootPages + pageRoutes,
single: posts.length,
category: categoryPages,
tag: tagPages,

View File

@@ -44,6 +44,12 @@ export interface SitemapArchiveMetadata {
latestPostUpdatedAt: string;
}
export interface CalendarArchiveData {
years: Record<string, number>;
months: Record<string, number>;
days: Record<string, number>;
}
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, ']]]]><![CDATA[>');
}
export function buildCalendarArchiveData(posts: PostData[]): CalendarArchiveData {
const yearCounts = new Map<string, number>();
const monthCounts = new Map<string, number>();
const dayCounts = new Map<string, number>();
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<string, number>): Record<string, number> => {
const result: Record<string, number> = {};
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;

View File

@@ -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<string, PreviewAssetDefinition> = {
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 = {

View File

@@ -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);
});
}
})();`;

View File

@@ -6,6 +6,7 @@
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
{% render 'partials/styles' %}
@@ -14,4 +15,6 @@
<script defer src="/assets/d3.layout.cloud.js"></script>
<script defer src="/assets/tag-cloud.js"></script>
<script defer src="/assets/lightbox.min.js"></script>
<script defer src="/assets/vanilla-calendar.min.js"></script>
<script defer src="/assets/calendar-runtime.js"></script>
</head>

View File

@@ -1,5 +1,48 @@
{% if menu_items and menu_items.size > 0 %}
<nav class="blog-menu">
<nav class="blog-menu">
{% if menu_items and menu_items.size > 0 %}
{% render 'partials/menu-items', items: menu_items %}
</nav>
{% endif %}
{% endif %}
<div class="blog-menu-calendar">
<button
type="button"
class="blog-menu-calendar-button"
data-blog-calendar-toggle
aria-label="{{ 'render.calendar.open' | i18n: language }}"
title="{{ 'render.calendar.open' | i18n: language }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<rect x="3" y="5" width="18" height="16" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="8" y1="3" x2="8" y2="7"></line>
<line x1="16" y1="3" x2="16" y2="7"></line>
</svg>
</button>
<section
id="blog-calendar"
class="blog-calendar-panel"
data-blog-calendar-panel
data-i18n-loading="{{ 'render.calendar.loading' | i18n: language }}"
data-i18n-error="{{ 'render.calendar.error' | i18n: language }}"
hidden
>
<header class="blog-calendar-header">
<strong>{{ 'render.calendar.title' | i18n: language }}</strong>
<button
type="button"
class="blog-calendar-close"
data-blog-calendar-close
aria-label="{{ 'render.calendar.close' | i18n: language }}"
title="{{ 'render.calendar.close' | i18n: language }}"
>
×
</button>
</header>
<div class="blog-calendar-content">
<div data-blog-calendar-root></div>
<p class="blog-calendar-status" data-blog-calendar-status>{{ 'render.calendar.loading' | i18n: language }}</p>
</div>
</section>
</div>
</nav>

View File

@@ -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; }