From ae28a270933b1dc9838b3bcb071b687a4590c92d Mon Sep 17 00:00:00 2001 From: hugo Date: Tue, 17 Feb 2026 21:51:37 +0100 Subject: [PATCH] feat: date and separator styling --- src/main/engine/PreviewServer.ts | 73 ++++++++++++++++++++++++--- tests/engine/PreviewServer.test.ts | 80 ++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 8 deletions(-) diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 971ee2d..db323af 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -309,6 +309,20 @@ function buildCanonicalPostPath(post: PostData): string { return `/${year}/${month}/${day}/${post.slug}`; } +function formatArchiveDate(date: Date): string { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = String(date.getFullYear()); + return `${day}.${month}.${year}`; +} + +function getArchiveDateKey(date: Date): string { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + function getPageHtml(content: string, title: string, language: string): string { return ` @@ -325,6 +339,12 @@ function getPageHtml(content: string, title: string, language: string): string { .post { border: 1px solid var(--muted-border-color); padding: 1rem; background: var(--card-background-color); } .post iframe { width: 100%; min-height: 20rem; } .macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; } + .archive-day-group { display: grid; grid-template-columns: 5.25rem 1fr; gap: 1.25rem; align-items: stretch; } + .archive-day-marker { display: flex; justify-content: center; align-items: center; color: var(--muted-color); } + .archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; } + .archive-day-posts { display: grid; gap: 1rem; } + .archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--color); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; } + .archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; } @@ -549,14 +569,14 @@ export class PreviewServer { if (tagMatch) { const tag = tagMatch[1]; const posts = await this.loadPublishedSnapshots({ status: 'published', tags: [tag] }, pageOptions); - return this.renderPostList(posts, rewriteContext); + return this.renderPostList(posts, rewriteContext, { archiveGrouping: true }); } const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/); if (categoryMatch) { const category = categoryMatch[1]; const posts = await this.loadPublishedSnapshots({ status: 'published', categories: [category] }, pageOptions); - return this.renderPostList(posts, rewriteContext); + return this.renderPostList(posts, rewriteContext, { archiveGrouping: true }); } const daySlugMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/); @@ -577,7 +597,7 @@ export class PreviewServer { const month = Number(dayMatch[2]); const day = Number(dayMatch[3]); const posts = await this.loadPostsForDay(year, month, day, pageOptions); - return this.renderPostList(posts, rewriteContext); + return this.renderPostList(posts, rewriteContext, { archiveGrouping: true }); } const monthMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})$/); @@ -586,14 +606,14 @@ export class PreviewServer { const month = Number(monthMatch[2]); if (month < 1 || month > 12) return null; const posts = await this.loadPublishedSnapshots({ status: 'published', year, month: month - 1 }, pageOptions); - return this.renderPostList(posts, rewriteContext); + return this.renderPostList(posts, rewriteContext, { archiveGrouping: true }); } const yearMatch = pagedPathname.match(/^\/(\d{4})$/); if (yearMatch) { const year = Number(yearMatch[1]); const posts = await this.loadPublishedSnapshots({ status: 'published', year }, pageOptions); - return this.renderPostList(posts, rewriteContext); + return this.renderPostList(posts, rewriteContext, { archiveGrouping: true }); } const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/); @@ -719,7 +739,11 @@ export class PreviewServer { return snapshots; } - private async renderPostList(posts: PostData[], rewriteContext: HtmlRewriteContext): Promise { + private async renderPostList( + posts: PostData[], + rewriteContext: HtmlRewriteContext, + options?: { archiveGrouping?: boolean }, + ): Promise { const renderablePosts = await Promise.all(posts.map(async (post) => { if (post.status === 'published' && !post.content) { const fullPost = await this.postEngine.getPost(post.id); @@ -728,8 +752,41 @@ export class PreviewServer { return post; })); - const rendered = await Promise.all(renderablePosts.map((post) => renderPostHtml(post, rewriteContext))); - return rendered.join('\n'); + if (!options?.archiveGrouping) { + const rendered = await Promise.all(renderablePosts.map((post) => renderPostHtml(post, rewriteContext))); + return rendered.join('\n'); + } + + const groups: Array<{ dateLabel: string; posts: PostData[] }> = []; + let currentGroup: { key: string; dateLabel: string; posts: PostData[] } | null = null; + + for (const post of renderablePosts) { + const key = getArchiveDateKey(post.createdAt); + if (!currentGroup || currentGroup.key !== key) { + currentGroup = { + key, + dateLabel: formatArchiveDate(post.createdAt), + posts: [], + }; + groups.push(currentGroup); + } + + currentGroup.posts.push(post); + } + + const renderedGroups = await Promise.all(groups.map(async (group) => { + const renderedPosts = await Promise.all(group.posts.map((post) => renderPostHtml(post, rewriteContext))); + return `
${renderedPosts.join('\n')}
`; + })); + + return renderedGroups + .map((groupHtml, index) => { + if (index === renderedGroups.length - 1) { + return groupHtml; + } + return `${groupHtml}\n`; + }) + .join('\n'); } private async buildHtmlRewriteContext(): Promise { diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index db38679..f95e0d9 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -238,6 +238,42 @@ describe('PreviewServer', () => { expect(dayHtml).not.toContain('Month Post'); }); + it('renders archive pages grouped by day with rotated date markers and separators', async () => { + const posts = [ + makePost({ id: 'a1', slug: 'a1', title: 'A1', createdAt: new Date('2025-02-14T12:00:00.000Z') }), + makePost({ id: 'a2', slug: 'a2', title: 'A2', createdAt: new Date('2025-02-14T08:00:00.000Z') }), + makePost({ id: 'b1', slug: 'b1', title: 'B1', createdAt: new Date('2025-02-13T09:00:00.000Z') }), + ]; + + server = new PreviewServer({ + postEngine: makeEngine(posts), + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const html = await (await fetch(`${server.getBaseUrl()}/2025/2/`)).text(); + + expect(html).toContain('archive-day-group'); + expect(html).toContain('archive-day-marker'); + expect(html).toContain('14.02.2025'); + expect(html).toContain('13.02.2025'); + + const markerCount = (html.match(/class="archive-day-marker"/g) || []).length; + expect(markerCount).toBe(2); + + const separatorCount = (html.match(/class="archive-day-separator"/g) || []).length; + expect(separatorCount).toBe(1); + + expect(html).toContain('.archive-day-separator { position: relative; height: 2px;'); + expect(html).toContain('color: var(--color);'); + expect(html).toContain('border-top: 1px solid currentColor;'); + expect(html).toContain('opacity: .18;'); + expect(html).toContain('.archive-day-separator::before'); + expect(html).toContain('linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%)'); + }); + it('supports day-and-slug post route', async () => { const post = makePost({ id: 'one', title: 'Single Post', slug: 'single-post', createdAt: new Date('2025-02-14T10:00:00.000Z') }); @@ -284,6 +320,50 @@ describe('PreviewServer', () => { expect(pageHtml).not.toContain('About Blog Post'); }); + it('renders tag and category pages with archive-style day grouping', async () => { + const tagDayOneA = makePost({ + id: 'tag-day-one-a', + title: 'Tag Day One A', + slug: 'tag-day-one-a', + tags: ['dev'], + categories: ['news'], + createdAt: new Date('2025-03-10T14:00:00.000Z'), + }); + const tagDayOneB = makePost({ + id: 'tag-day-one-b', + title: 'Tag Day One B', + slug: 'tag-day-one-b', + tags: ['dev'], + categories: ['news'], + createdAt: new Date('2025-03-10T08:00:00.000Z'), + }); + const tagDayTwo = makePost({ + id: 'tag-day-two', + title: 'Tag Day Two', + slug: 'tag-day-two', + tags: ['dev'], + categories: ['news'], + createdAt: new Date('2025-03-09T09:00:00.000Z'), + }); + + server = new PreviewServer({ + postEngine: makeEngine([tagDayOneA, tagDayOneB, tagDayTwo]), + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const tagHtml = await (await fetch(`${server.getBaseUrl()}/tag/dev/`)).text(); + expect(tagHtml).toContain('class="archive-day-group"'); + expect(tagHtml).toContain('10.03.2025'); + expect(tagHtml).toContain('09.03.2025'); + + const categoryHtml = await (await fetch(`${server.getBaseUrl()}/category/news/`)).text(); + expect(categoryHtml).toContain('class="archive-day-group"'); + expect(categoryHtml).toContain('class="archive-day-separator"'); + }); + it('supports /page/ suffix on list routes', async () => { const baseTimestamp = Date.UTC(2020, 9, 31, 23, 59, 59); const posts = Array.from({ length: 120 }).map((_, index) => {