import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { tmpdir } from 'node:os'; import type { PostData, PostFilter } from '../../src/main/engine/PostEngine'; import { PreviewServer } from '../../src/main/engine/PreviewServer'; type PostEngineLike = { getPostsFiltered: (filter: PostFilter) => Promise; getPost: (id: string) => Promise; hasPublishedVersion: (id: string) => Promise; getPublishedVersion: (id: string) => Promise; setProjectContext: (projectId: string, dataDir?: string) => void; }; type SettingsEngineLike = { getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string } | null>; setProjectContext: (projectId: string, dataDir?: string) => void; }; 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, createdAt, updatedAt, publishedAt: overrides.publishedAt, tags: overrides.tags ?? [], categories: overrides.categories ?? [], }; } function makeEngine(posts: PostData[]): PostEngineLike { const byId = new Map(posts.map((post) => [post.id, post])); return { setProjectContext: vi.fn(), async hasPublishedVersion(): Promise { return false; }, async getPublishedVersion(): Promise { return null; }, async getPost(id: string): Promise { return byId.get(id) ?? null; }, async getPostsFiltered(filter: PostFilter): Promise { let result = posts.filter((post) => post.status === (filter.status ?? post.status)); if (filter.tags && filter.tags.length > 0) { result = result.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag))); } if (filter.categories && filter.categories.length > 0) { result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category))); } if ((filter as any).excludeCategories && (filter as any).excludeCategories.length > 0) { result = result.filter((post) => !(filter as any).excludeCategories.some((category: string) => post.categories.includes(category))); } if (filter.year !== undefined) { result = result.filter((post) => post.createdAt.getUTCFullYear() === filter.year); } if (filter.month !== undefined && filter.year !== undefined) { result = result.filter((post) => post.createdAt.getUTCMonth() === filter.month); } if (filter.startDate) { result = result.filter((post) => post.createdAt >= filter.startDate!); } if (filter.endDate) { result = result.filter((post) => post.createdAt <= filter.endDate!); } return result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); }, }; } function makeSettings(maxPostsPerPage = 50): SettingsEngineLike { return { setProjectContext: vi.fn(), async getProjectMetadata(): Promise<{ maxPostsPerPage?: number } | null> { return { maxPostsPerPage }; }, }; } function makeMediaEngine(mediaItems: Array<{ id: string; filename: string; originalName: string; createdAt: Date }>) { return { async getAllMedia() { return mediaItems; }, }; } function makePostMediaEngine(linksByPostId: Record>) { return { setProjectContext: vi.fn(), async getLinkedMediaDataForPost(postId: string) { return linksByPostId[postId] ?? []; }, }; } describe('PreviewServer', () => { let server: PreviewServer; let tempDir: string | null = null; afterEach(async () => { if (server) { await server.stop(); } if (tempDir) { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } }); beforeEach(() => { vi.clearAllMocks(); }); it('binds to localhost and serves root route', async () => { const posts = [ makePost({ id: '1', slug: 'newest', title: 'Newest', createdAt: new Date('2025-01-03T10:00:00.000Z') }), makePost({ id: '2', slug: 'older', title: 'Older', createdAt: new Date('2025-01-02T10:00:00.000Z') }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); const port = await server.start(0); expect(port).toBeGreaterThan(0); expect(server.getBaseUrl()).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('
'); expect(html).toContain('

Newest

'); expect(html).toContain('archive-day-marker'); expect(html).toContain('03.01.2025'); expect(html).toContain('02.01.2025'); }); it('uses local CSS/JS assets and serves them from the preview server', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const rootResponse = await fetch(`${server.getBaseUrl()}/`); expect(rootResponse.status).toBe(200); const rootHtml = await rootResponse.text(); expect(rootHtml).toContain('href="/assets/pico.min.css"'); expect(rootHtml).toContain('href="/assets/lightbox.min.css"'); expect(rootHtml).toContain('src="/assets/lightbox.min.js"'); expect(rootHtml).toContain('src="/assets/d3.layout.cloud.js"'); expect(rootHtml).toContain('src="/assets/tag-cloud.js"'); expect(rootHtml).not.toContain('function parseWords('); expect(rootHtml).not.toContain('cdn.jsdelivr.net'); const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`); expect(picoResponse.status).toBe(200); expect(picoResponse.headers.get('content-type')).toContain('text/css'); const lightboxCssResponse = await fetch(`${server.getBaseUrl()}/assets/lightbox.min.css`); expect(lightboxCssResponse.status).toBe(200); expect(lightboxCssResponse.headers.get('content-type')).toContain('text/css'); const lightboxJsResponse = await fetch(`${server.getBaseUrl()}/assets/lightbox.min.js`); expect(lightboxJsResponse.status).toBe(200); expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript'); const d3CloudJsResponse = await fetch(`${server.getBaseUrl()}/assets/d3.layout.cloud.js`); expect(d3CloudJsResponse.status).toBe(200); expect(d3CloudJsResponse.headers.get('content-type')).toContain('application/javascript'); const tagCloudJsResponse = await fetch(`${server.getBaseUrl()}/assets/tag-cloud.js`); expect(tagCloudJsResponse.status).toBe(200); expect(tagCloudJsResponse.headers.get('content-type')).toContain('application/javascript'); const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`); expect(lightboxPrevImageResponse.status).toBe(200); expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png'); const lightboxLoadingImageResponse = await fetch(`${server.getBaseUrl()}/images/loading.gif`); expect(lightboxLoadingImageResponse.status).toBe(200); expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif'); }); it('renders tag_cloud macro with normalized tag usage and tag archive links', async () => { const posts = [ makePost({ id: 'tags-1', slug: 'tag-cloud-source', title: 'Tag Cloud Source', tags: ['TypeScript', 'Electron'], content: '[[tag_cloud]]', }), makePost({ id: 'tags-2', slug: 'second', title: 'Second', tags: ['TypeScript'], }), makePost({ id: 'tags-3', slug: 'third', title: 'Third', tags: ['Electron', 'SQLite', 'TypeScript'], }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/posts/tag-cloud-source`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('class="macro-tag-cloud"'); expect(html).toContain('data-tag-cloud="true"'); expect(html).toContain('data-orientation="horizontal"'); expect(html).toContain('data-color-distribution="quantile"'); expect(html).toContain('data-color-easing="0.7"'); expect(html).toContain('data-color-theme="pico"'); expect(html).toContain('TypeScript'); expect(html).toContain('/tag/TypeScript/'); expect(html).toContain('/tag/Electron/'); expect(html).toContain('/tag/SQLite/'); expect(html).toContain('"count":3'); expect(html).toContain('"count":2'); expect(html).toContain('"count":1'); expect(html).not.toContain('"color"'); }); it('supports tag_cloud orientation parameter modes', async () => { const posts = [ makePost({ id: 'orientation-1', slug: 'orientation-hv', title: 'Orientation HV', tags: ['alpha', 'beta'], content: '[[tag_cloud orientation="mixed_hv"]]', }), makePost({ id: 'orientation-2', slug: 'orientation-diagonal', title: 'Orientation Diagonal', tags: ['alpha'], content: '[[tag_cloud orientation="mixed_diagonal"]]', }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const hvResponse = await fetch(`${server.getBaseUrl()}/posts/orientation-hv`); expect(hvResponse.status).toBe(200); const hvHtml = await hvResponse.text(); expect(hvHtml).toContain('data-orientation="mixed-hv"'); const diagonalResponse = await fetch(`${server.getBaseUrl()}/posts/orientation-diagonal`); expect(diagonalResponse.status).toBe(200); const diagonalHtml = await diagonalResponse.text(); expect(diagonalHtml).toContain('data-orientation="mixed-diagonal"'); }); it('serves draft content for single post route when draft query flag and postId are provided', async () => { const publishedPost = makePost({ id: 'post-1', slug: 'shared-slug', title: 'Published Title', content: 'Published body', status: 'published', createdAt: new Date('2025-01-03T10:00:00.000Z'), }); const draftPost = makePost({ id: 'post-2', slug: 'shared-slug', title: 'Draft Title', content: 'Draft-only body', status: 'draft', createdAt: new Date('2025-01-03T10:00:00.000Z'), }); server = new PreviewServer({ postEngine: makeEngine([publishedPost, draftPost]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const publishedResponse = await fetch(`${server.getBaseUrl()}/posts/shared-slug`); expect(publishedResponse.status).toBe(200); const publishedHtml = await publishedResponse.text(); expect(publishedHtml).toContain('Published Title'); expect(publishedHtml).toContain('Published body'); expect(publishedHtml).not.toContain('Draft-only body'); const draftResponse = await fetch(`${server.getBaseUrl()}/posts/shared-slug?draft=true&postId=post-2`); expect(draftResponse.status).toBe(200); const draftHtml = await draftResponse.text(); expect(draftHtml).toContain('Draft Title'); expect(draftHtml).toContain('Draft-only body'); expect(draftHtml).not.toContain('Published body'); }); it('uses selected pico theme stylesheet from project metadata', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { maxPostsPerPage: 50, picoTheme: 'slate', }; }, } as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const rootResponse = await fetch(`${server.getBaseUrl()}/`); expect(rootResponse.status).toBe(200); const rootHtml = await rootResponse.text(); expect(rootHtml).toContain('href="/assets/pico.slate.min.css"'); expect(rootHtml).not.toContain('href="/assets/pico.min.css"'); const themedCss = await fetch(`${server.getBaseUrl()}/assets/pico.slate.min.css`); expect(themedCss.status).toBe(200); expect(themedCss.headers.get('content-type')).toContain('text/css'); }); it('supports preview mode override for style preview route', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { maxPostsPerPage: 50, picoTheme: 'slate', }; }, } as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/__style-preview?theme=slate&mode=dark`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain(''); expect(html).toContain('href="/assets/pico.slate.min.css"'); }); it('limits list routes to 50 posts', async () => { const posts = Array.from({ length: 60 }).map((_, index) => makePost({ id: `p-${index + 1}`, slug: `slug-${index + 1}`, title: `Post ${index + 1}`, createdAt: new Date(Date.UTC(2025, 0, 1, 0, 0, index)), }) ); server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); const html = await response.text(); const renderedPosts = (html.match(/
/g) || []).length; expect(renderedPosts).toBe(50); }); it('supports year, month, and day archive routes', async () => { const matchingDay = makePost({ id: 'd1', slug: 'day-post', title: 'Day Post', createdAt: new Date('2025-02-14T10:00:00.000Z') }); const sameMonth = makePost({ id: 'm1', slug: 'month-post', title: 'Month Post', createdAt: new Date('2025-02-10T10:00:00.000Z') }); const sameYear = makePost({ id: 'y1', slug: 'year-post', title: 'Year Post', createdAt: new Date('2025-08-01T10:00:00.000Z') }); const differentYear = makePost({ id: 'o1', slug: 'other', title: 'Other', createdAt: new Date('2024-02-14T10:00:00.000Z') }); server = new PreviewServer({ postEngine: makeEngine([matchingDay, sameMonth, sameYear, differentYear]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const yearHtml = await (await fetch(`${server.getBaseUrl()}/2025/`)).text(); expect(yearHtml).toContain('Day Post'); expect(yearHtml).toContain('Month Post'); expect(yearHtml).toContain('Year Post'); expect(yearHtml).not.toContain('Other'); const monthHtml = await (await fetch(`${server.getBaseUrl()}/2025/2/`)).text(); expect(monthHtml).toContain('Day Post'); expect(monthHtml).toContain('Month Post'); expect(monthHtml).not.toContain('Year Post'); const dayHtml = await (await fetch(`${server.getBaseUrl()}/2025/2/14/`)).text(); expect(dayHtml).toContain('Day Post'); 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(--pico-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') }); server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/2025/2/14/single-post/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('Single Post'); expect(html).toContain('data-template="single-post"'); expect(html).toContain('.single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; }'); }); it('renders single post title as h1', async () => { const post = makePost({ id: 'single-title', title: 'Explicit Single Post Title', slug: 'single-title', createdAt: new Date('2025-02-14T10:00:00.000Z'), content: 'Plain body without markdown heading', }); server = new PreviewServer({ postEngine: makeEngine([post]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/2025/2/14/single-title/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('

Explicit Single Post Title

'); }); it('uses blog description as h1 on first date archive page and date range h1 on later pages', async () => { const posts = [ makePost({ id: 'd-1', slug: 'd-1', title: 'D1', content: 'Body 1', createdAt: new Date('2020-02-05T10:00:00.000Z'), }), makePost({ id: 'd-2', slug: 'd-2', title: 'D2', content: 'Body 2', createdAt: new Date('2020-02-04T10:00:00.000Z'), }), makePost({ id: 'd-3', slug: 'd-3', title: 'D3', content: 'Body 3', createdAt: new Date('2020-01-02T10:00:00.000Z'), }), makePost({ id: 'd-4', slug: 'd-4', title: 'D4', content: 'Body 4', createdAt: new Date('2020-01-01T10:00:00.000Z'), }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { description: 'Meine Blog Beschreibung', maxPostsPerPage: 2, }; }, }, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const firstPageHtml = await (await fetch(`${server.getBaseUrl()}/`)).text(); expect(firstPageHtml).toContain('

Meine Blog Beschreibung

'); const secondPageHtml = await (await fetch(`${server.getBaseUrl()}/page/2/`)).text(); expect(secondPageHtml).toContain('

Archiv 1.1.2020 - 2.1.2020

'); }); it('renders month archive heading with German month name on first page', async () => { const posts = [ makePost({ id: 'm-1', slug: 'm-1', title: 'M1', content: 'Body 1', createdAt: new Date('2020-02-05T10:00:00.000Z') }), makePost({ id: 'm-2', slug: 'm-2', title: 'M2', content: 'Body 2', createdAt: new Date('2020-02-04T10:00:00.000Z') }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { description: 'Meine Blog Beschreibung', maxPostsPerPage: 50, }; }, }, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const monthPageHtml = await (await fetch(`${server.getBaseUrl()}/2020/2/`)).text(); expect(monthPageHtml).toContain('

Archiv Februar 2020

'); }); it('renders tag heading on first page and adds date range on later pages', async () => { const posts = [ makePost({ id: 't-1', slug: 't-1', title: 'T1', content: 'Body 1', tags: ['dev'], createdAt: new Date('2020-02-05T10:00:00.000Z') }), makePost({ id: 't-2', slug: 't-2', title: 'T2', content: 'Body 2', tags: ['dev'], createdAt: new Date('2020-02-04T10:00:00.000Z') }), makePost({ id: 't-3', slug: 't-3', title: 'T3', content: 'Body 3', tags: ['dev'], createdAt: new Date('2020-01-02T10:00:00.000Z') }), makePost({ id: 't-4', slug: 't-4', title: 'T4', content: 'Body 4', tags: ['dev'], createdAt: new Date('2020-01-01T10:00:00.000Z') }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { description: 'Beschreibung', maxPostsPerPage: 2 }; }, }, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const firstPageHtml = await (await fetch(`${server.getBaseUrl()}/tag/dev/`)).text(); expect(firstPageHtml).toContain('

dev

'); const secondPageHtml = await (await fetch(`${server.getBaseUrl()}/tag/dev/page/2/`)).text(); expect(secondPageHtml).toContain('

dev - 1.1.2020 - 2.1.2020

'); }); it('renders category heading on first page and adds date range on later pages', async () => { const posts = [ makePost({ id: 'c-1', slug: 'c-1', title: 'C1', content: 'Body 1', categories: ['news'], createdAt: new Date('2020-02-05T10:00:00.000Z') }), makePost({ id: 'c-2', slug: 'c-2', title: 'C2', content: 'Body 2', categories: ['news'], createdAt: new Date('2020-02-04T10:00:00.000Z') }), makePost({ id: 'c-3', slug: 'c-3', title: 'C3', content: 'Body 3', categories: ['news'], createdAt: new Date('2020-01-02T10:00:00.000Z') }), makePost({ id: 'c-4', slug: 'c-4', title: 'C4', content: 'Body 4', categories: ['news'], createdAt: new Date('2020-01-01T10:00:00.000Z') }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { description: 'Beschreibung', maxPostsPerPage: 2 }; }, }, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const firstPageHtml = await (await fetch(`${server.getBaseUrl()}/category/news/`)).text(); expect(firstPageHtml).toContain('

news

'); const secondPageHtml = await (await fetch(`${server.getBaseUrl()}/category/news/page/2/`)).text(); expect(secondPageHtml).toContain('

news - 1.1.2020 - 2.1.2020

'); }); it('filters out categories disabled for list rendering on list routes', async () => { const posts = [ makePost({ id: 'list-1', slug: 'list-1', title: 'List Included', categories: ['article'], createdAt: new Date('2025-02-05T10:00:00.000Z') }), makePost({ id: 'list-2', slug: 'list-2', title: 'List Excluded', categories: ['page'], createdAt: new Date('2025-02-04T10:00:00.000Z') }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { maxPostsPerPage: 50, categorySettings: { article: { renderInLists: true, showTitle: true }, page: { renderInLists: false, showTitle: true }, }, }; }, } as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const html = await (await fetch(`${server.getBaseUrl()}/`)).text(); expect(html).toContain('List Included'); expect(html).not.toContain('List Excluded'); }); it('suppresses all list category titles when any assigned category has showTitle disabled', async () => { const posts = [ makePost({ id: 'ct-1', slug: 'ct-1', title: 'Category Title Test', categories: ['aside', 'article'], content: 'Body without markdown headings', createdAt: new Date('2025-02-05T10:00:00.000Z'), }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { maxPostsPerPage: 50, categorySettings: { article: { renderInLists: true, showTitle: true }, aside: { renderInLists: true, showTitle: false }, }, }; }, } as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const html = await (await fetch(`${server.getBaseUrl()}/`)).text(); expect(html).not.toContain('

Category Title Test

'); }); it('renders post title in list when category titles are enabled', async () => { const posts = [ makePost({ id: 'pt-1', slug: 'pt-1', title: 'Article Title', categories: ['article'], content: 'Body without markdown headings', createdAt: new Date('2025-02-06T10:00:00.000Z'), }), ]; server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { maxPostsPerPage: 50, categorySettings: { article: { renderInLists: true, showTitle: true }, aside: { renderInLists: true, showTitle: false }, page: { renderInLists: false, showTitle: true }, }, }; }, } as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const html = await (await fetch(`${server.getBaseUrl()}/`)).text(); expect(html).toContain('

Article Title

'); expect(html).not.toContain(''); }); it('supports tag, category, and page-slug routes', async () => { const tagged = makePost({ id: 'tag1', title: 'Tagged', slug: 'tagged', tags: ['dev'] }); const categorized = makePost({ id: 'cat1', title: 'Categorized', slug: 'categorized', categories: ['news'] }); const page = makePost({ id: 'page1', title: 'About', slug: 'about', categories: ['page'] }); const regular = makePost({ id: 'post1', title: 'About Blog Post', slug: 'about', categories: ['blog'] }); server = new PreviewServer({ postEngine: makeEngine([tagged, categorized, page, regular]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const tagHtml = await (await fetch(`${server.getBaseUrl()}/tag/dev/`)).text(); expect(tagHtml).toContain('Tagged'); expect(tagHtml).not.toContain('Categorized'); const categoryHtml = await (await fetch(`${server.getBaseUrl()}/category/news/`)).text(); expect(categoryHtml).toContain('Categorized'); expect(categoryHtml).not.toContain('Tagged'); const pageResponse = await fetch(`${server.getBaseUrl()}/about/`); expect(pageResponse.status).toBe(200); const pageHtml = await pageResponse.text(); expect(pageHtml).toContain('About'); 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) => { const number = index + 1; return makePost({ id: `hist-${number}`, slug: `history-${number}`, title: `History ${number}`, createdAt: new Date(baseTimestamp - index * 1000), tags: ['dev'], categories: ['news'], }); }); server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const rootPageTwoHtml = await (await fetch(`${server.getBaseUrl()}/page/2/`)).text(); expect(rootPageTwoHtml).toContain('data-template="post-list"'); expect(rootPageTwoHtml).toContain('data-first-page="false"'); expect(rootPageTwoHtml).toContain('data-last-page="false"'); expect(rootPageTwoHtml).toContain('href="/"'); expect(rootPageTwoHtml).toContain('href="/page/3/"'); expect(rootPageTwoHtml).toContain('class="preview-pagination-link"'); expect(rootPageTwoHtml).not.toContain('role="button"'); expect(rootPageTwoHtml).toContain('History 51'); expect(rootPageTwoHtml).toContain('History 100'); expect(rootPageTwoHtml).not.toContain('History 50'); expect(rootPageTwoHtml).not.toContain('History 101'); const yearPageThreeHtml = await (await fetch(`${server.getBaseUrl()}/2020/page/3/`)).text(); expect(yearPageThreeHtml).toContain('data-last-page="true"'); expect(yearPageThreeHtml).toContain('href="/2020/page/2/"'); expect(yearPageThreeHtml).not.toContain('href="/2020/page/4/"'); expect(yearPageThreeHtml).toContain('History 101'); expect(yearPageThreeHtml).toContain('History 120'); expect(yearPageThreeHtml).not.toContain('History 100'); const monthPageTwoHtml = await (await fetch(`${server.getBaseUrl()}/2020/10/page/2/`)).text(); expect(monthPageTwoHtml).toContain('History 51'); expect(monthPageTwoHtml).toContain('History 100'); expect(monthPageTwoHtml).not.toContain('History 50'); const categoryPageTwoHtml = await (await fetch(`${server.getBaseUrl()}/category/news/page/2/`)).text(); expect(categoryPageTwoHtml).toContain('History 51'); expect(categoryPageTwoHtml).toContain('History 100'); expect(categoryPageTwoHtml).not.toContain('History 50'); const tagPageThreeHtml = await (await fetch(`${server.getBaseUrl()}/tag/dev/page/3/`)).text(); expect(tagPageThreeHtml).toContain('History 101'); expect(tagPageThreeHtml).toContain('History 120'); expect(tagPageThreeHtml).not.toContain('History 100'); }); it('uses max posts per page from preferences', async () => { const posts = Array.from({ length: 20 }).map((_, index) => makePost({ id: `pref-${index + 1}`, slug: `pref-slug-${index + 1}`, title: `Pref Post ${index + 1}`, createdAt: new Date(Date.UTC(2025, 0, 1, 0, 0, index)), }) ); server = new PreviewServer({ postEngine: makeEngine(posts), settingsEngine: makeSettings(7), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); const html = await response.text(); const renderedPosts = (html.match(/
/g) || []).length; expect(renderedPosts).toBe(7); }); it('uses project description from metadata in page title', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { name: 'My Great Blog', description: 'A wonderful publication', maxPostsPerPage: 50, }; }, }, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('A wonderful publication'); expect(html).not.toContain('My Great Blog'); expect(html).not.toContain('Blog Preview'); }); it('uses mainLanguage from metadata for html lang attribute', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return { name: 'My Great Blog', mainLanguage: 'de', maxPostsPerPage: 50, }; }, }, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain(''); }); it('initializes metadata before reading language when supported by settings engine', async () => { let initialized = false; server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: { setProjectContext: vi.fn(), isInitialized: vi.fn(() => initialized), syncOnStartup: vi.fn(async () => { initialized = true; }), async getProjectMetadata() { return initialized ? { name: 'My Great Blog', mainLanguage: 'fr', maxPostsPerPage: 50 } : null; }, } as any, getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain(''); }); it('falls back to active project name in page title when metadata is unavailable', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: { setProjectContext: vi.fn(), async getProjectMetadata() { return null; }, }, getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default', projectName: 'Configured Project Name', } as any), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('Configured Project Name'); expect(html).not.toContain('Blog Preview'); }); it('rewrites supported markdown links to preview-safe URLs while leaving external links unchanged', async () => { const targetBySlug = makePost({ id: 'target-1', slug: 'target-post', title: 'Target Post', createdAt: new Date('2025-02-14T10:00:00.000Z'), content: '# Target', }); const targetByYearMonth = makePost({ id: 'target-2', slug: 'archive-post', title: 'Archive Post', createdAt: new Date('2025-02-10T10:00:00.000Z'), content: '# Archive', }); const legacyTarget = makePost({ id: 'target-3', slug: 'legacy-post', title: 'Legacy Post', createdAt: new Date('2025-03-01T10:00:00.000Z'), content: '# Legacy', }); const post = makePost({ id: 'rewrite-1', slug: 'rewrite-test', title: 'Rewrite Test', content: [ '[Post by slug](/posts/target-post)', '[Post by year/month](/posts/2025/02/archive-post)', '[Legacy post link](post/legacy-post)', '![Local image](media/2025/02/example.jpg)', '[External](https://example.com/path)', ].join('\n\n'), }); server = new PreviewServer({ postEngine: makeEngine([post, targetBySlug, targetByYearMonth, legacyTarget]), mediaEngine: makeMediaEngine([ { id: 'media-guid-1', filename: '3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg', originalName: 'example.jpg', createdAt: new Date('2025-02-03T10:00:00.000Z'), }, ]) as any, settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('href="/2025/02/14/target-post"'); expect(html).toContain('href="/2025/02/10/archive-post"'); expect(html).toContain('href="/2025/03/01/legacy-post"'); expect(html).toContain('src="/media/2025/02/3b94f5d1-91f5-4c9b-a8d4-6f3bf8f045cf.jpg"'); expect(html).toContain('href="https://example.com/path"'); }); it('renders gallery and photo_album macros as interactive lightbox markup in preview', async () => { const post = makePost({ id: 'macro-1', slug: 'macro-preview', title: 'Macro Preview', content: [ '[[gallery columns="2" caption="Trip Photos"]]', '[[photo_album year="2025" month="2"]]', ].join('\n\n'), }); server = new PreviewServer({ postEngine: makeEngine([post]), mediaEngine: makeMediaEngine([ { id: 'media-1', filename: 'linked-1.jpg', originalName: 'linked-1.jpg', createdAt: new Date('2025-02-10T10:00:00.000Z'), linkedPostIds: ['macro-1'], } as any, { id: 'media-2', filename: 'linked-2.jpg', originalName: 'linked-2.jpg', createdAt: new Date('2025-02-12T10:00:00.000Z'), linkedPostIds: ['macro-1'], } as any, { id: 'media-3', filename: 'archive.jpg', originalName: 'archive.jpg', createdAt: new Date('2025-02-09T10:00:00.000Z'), linkedPostIds: [], } as any, ]) as any, settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).not.toContain('Gallery preview is not interactive yet.'); expect(html).not.toContain('Photo archive preview is not interactive yet.'); expect(html).toContain('class="macro-gallery gallery-cols-2"'); expect(html).toContain('data-lightbox="gallery-macro-1"'); expect(html).toContain('/media/2025/02/linked-1.jpg'); expect(html).toContain('/media/2025/02/linked-2.jpg'); expect(html).toContain('Trip Photos'); expect(html).toContain('class="macro-photo-archive photo-archive-single-month"'); expect(html).toContain('data-lightbox="photo-archive-2025-02"'); expect(html).toContain('/media/2025/02/archive.jpg'); }); it('resolves gallery linked images via post-media links even when media.linkedPostIds is empty', async () => { const post = makePost({ id: 'macro-junction-1', slug: 'macro-junction-preview', title: 'Macro Junction Preview', content: '[[gallery columns="2"]]', }); server = new PreviewServer({ postEngine: makeEngine([post]), mediaEngine: makeMediaEngine([ { id: 'junction-media-1', filename: 'junction-1.jpg', originalName: 'junction-1.jpg', createdAt: new Date('2025-02-10T10:00:00.000Z'), linkedPostIds: [], } as any, ]) as any, postMediaEngine: makePostMediaEngine({ 'macro-junction-1': [{ media: { id: 'junction-media-1' } }], }) as any, settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), } as any); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const html = await response.text(); expect(html).not.toContain('No linked images found.'); expect(html).toContain('/media/2025/02/junction-1.jpg'); }); it('serves media files from the active project data directory at /media/...', async () => { tempDir = await mkdtemp(path.join(tmpdir(), 'bds-preview-media-')); const mediaDir = path.join(tempDir, 'media', '2025', '02'); await mkdir(mediaDir, { recursive: true }); await writeFile(path.join(mediaDir, 'sample.jpg'), Buffer.from('fake-image-bytes')); server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir!, }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/media/2025/02/sample.jpg`); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toContain('image/jpeg'); const body = await response.text(); expect(body).toBe('fake-image-bytes'); }); it('uses published snapshot content and metadata for draft posts that have a published version', async () => { const draftWithPublished = makePost({ id: 'draft-1', status: 'draft', title: 'Draft Title', slug: 'draft-slug', content: '# Draft content must not leak', tags: ['draft-tag'], categories: ['draft-category'], createdAt: new Date('2025-02-14T10:00:00.000Z'), }); const publishedSnapshot = makePost({ id: 'draft-1', status: 'published', title: 'Published Title', slug: 'published-slug', content: '# Published content only', tags: ['published-tag'], categories: ['article'], createdAt: new Date('2025-02-14T10:00:00.000Z'), }); const engine = makeEngine([draftWithPublished]); engine.hasPublishedVersion = vi.fn(async (id: string) => id === 'draft-1'); engine.getPublishedVersion = vi.fn(async (id: string) => (id === 'draft-1' ? publishedSnapshot : null)); server = new PreviewServer({ postEngine: engine, settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const rootHtml = await (await fetch(`${server.getBaseUrl()}/`)).text(); expect(rootHtml).toContain('Published content only'); expect(rootHtml).not.toContain('Draft content must not leak'); const publishedSlugResponse = await fetch(`${server.getBaseUrl()}/posts/published-slug/`); expect(publishedSlugResponse.status).toBe(200); const draftSlugResponse = await fetch(`${server.getBaseUrl()}/posts/draft-slug/`); expect(draftSlugResponse.status).toBe(404); const publishedTagHtml = await (await fetch(`${server.getBaseUrl()}/tag/published-tag/`)).text(); expect(publishedTagHtml).toContain('Published content only'); const draftTagResponse = await fetch(`${server.getBaseUrl()}/tag/draft-tag/`); expect(draftTagResponse.status).toBe(404); const draftTagHtml = await draftTagResponse.text(); expect(draftTagHtml).not.toContain('Published content only'); }); it('discovers candidates via status-scoped DB filters for published and draft only', async () => { const published = makePost({ id: 'pub-1', status: 'published', slug: 'pub-1', content: '# Published one' }); const draft = makePost({ id: 'draft-1', status: 'draft', slug: 'draft-1', content: '# Draft one' }); const getPostsFiltered = vi.fn(async (filter: PostFilter) => { if (filter.status === 'published') return [published]; if (filter.status === 'draft') return [draft]; return []; }); const engine: PostEngineLike = { setProjectContext: vi.fn(), getPostsFiltered, getPost: vi.fn(async (id: string) => (id === published.id ? published : draft)), hasPublishedVersion: vi.fn(async (id: string) => id === draft.id), getPublishedVersion: vi.fn(async (id: string) => (id === draft.id ? makePost({ ...published, id: draft.id, slug: 'pub-draft', content: '# Published snapshot for draft' }) : null)), }; server = new PreviewServer({ postEngine: engine, settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); const statusValues = getPostsFiltered.mock.calls.map((args) => args[0]?.status); expect(statusValues.every((value) => value === 'published' || value === 'draft')).toBe(true); expect(statusValues).toContain('published'); expect(statusValues).toContain('draft'); }); it('loads published filesystem content only for rendered posts', async () => { const fullPublishedPosts = Array.from({ length: 60 }).map((_, index) => makePost({ id: `pub-full-${index + 1}`, slug: `pub-full-${index + 1}`, title: `Published Full ${index + 1}`, content: `# Published Full ${index + 1}`, status: 'published', createdAt: new Date(Date.UTC(2025, 0, 1, 0, 0, index)), }) ); const summaryPublishedPosts = fullPublishedPosts.map((post) => ({ ...post, content: '', })); const byId = new Map(fullPublishedPosts.map((post) => [post.id, post])); const getPost = vi.fn(async (id: string) => byId.get(id) ?? null); const engine: PostEngineLike = { setProjectContext: vi.fn(), getPost, hasPublishedVersion: vi.fn(async () => false), getPublishedVersion: vi.fn(async () => null), getPostsFiltered: vi.fn(async (filter: PostFilter) => { if (filter.status === 'published') { return summaryPublishedPosts; } if (filter.status === 'draft') { return []; } return []; }), }; server = new PreviewServer({ postEngine: engine, settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/`); expect(response.status).toBe(200); expect(getPost).toHaveBeenCalledTimes(50); }); it('renders custom 404 template for unknown routes', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), settingsEngine: makeSettings(50), getActiveProjectContext: async () => ({ projectId: 'default' }), }); await server.start(0); const response = await fetch(`${server.getBaseUrl()}/does-not-exist/`); expect(response.status).toBe(404); const html = await response.text(); expect(html).toContain('data-template="not-found"'); expect(html).toContain('class="not-found"'); }); });