diff --git a/README.md b/README.md index 11b1bc7..6f944af 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,20 @@ tags: ["nature", "sunset"] --- ``` +### Internal Link Formats + +Canonical formats for new content: + +- Post links: `/YYYY/MM/DD/slug` (example: `/2025/02/16/my-post`) +- Media links: `/media/YYYY/MM/file.ext` (example: `/media/2025/02/photo.jpg`) + +Also supported (legacy/alternative input formats): + +- Post links: `/posts/slug`, `/posts/YYYY/MM/slug`, `post/slug`, `post/YYYY/MM/slug` +- Media links: `media/YYYY/MM/file.ext` + +Preview HTML generation rewrites supported post/media link formats to preview-routable URLs. Markdown source remains unchanged except when inserting new media links from the editor, which now use `/media/...`. + ## Development ### Prerequisites diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 906f2b5..f916838 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -1,7 +1,9 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'; import { readFile } from 'node:fs/promises'; +import path from 'node:path'; import { marked } from 'marked'; import { getMetaEngine, type ProjectMetadata } from './MetaEngine'; +import { getMediaEngine, type MediaData } from './MediaEngine'; import { getPostEngine, type PostData, type PostFilter } from './PostEngine'; import { getProjectEngine } from './ProjectEngine'; @@ -25,10 +27,21 @@ interface MetaEngineContract { interface PreviewServerDependencies { postEngine: PostEngineContract; + mediaEngine: MediaEngineContract; settingsEngine: MetaEngineContract; getActiveProjectContext: () => Promise; } +interface HtmlRewriteContext { + canonicalPostPathBySlug: Map; + canonicalMediaPathBySourcePath: Map; +} + +interface MediaEngineContract { + getAllMedia: () => Promise; + setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void; +} + const DEFAULT_MAX_POSTS_PER_PAGE = 50; const MIN_MAX_POSTS_PER_PAGE = 1; const MAX_MAX_POSTS_PER_PAGE = 500; @@ -106,6 +119,102 @@ function parseMacroParams(paramString: string | undefined): Record { + const rewritten = normalizePreviewHref(href, rewriteContext); + return `href=${quote}${rewritten}${quote}`; + }) + .replace(/\bsrc=(['"])(.*?)\1/gi, (_fullMatch, quote: string, src: string) => { + const rewritten = normalizePreviewSrc(src, rewriteContext); + return `src=${quote}${rewritten}${quote}`; + }); +} + function renderMacro(name: string, params: Record, postId: string): string { if (name === 'youtube') { const id = escapeHtml(params.id || ''); @@ -136,14 +245,22 @@ function renderMacro(name: string, params: Record, postId: strin return ''; } -async function renderPostHtml(post: PostData): Promise { +async function renderPostHtml(post: PostData, rewriteContext: HtmlRewriteContext): Promise { const withMacros = post.content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => { const params = parseMacroParams(rawParams); return renderMacro(macroName.toLowerCase(), params, post.id); }); const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false }); - return `
${markdownHtml}
`; + const rewrittenHtml = rewriteRenderedHtmlUrls(markdownHtml, rewriteContext); + return `
${rewrittenHtml}
`; +} + +function buildCanonicalPostPath(post: PostData): string { + const year = post.createdAt.getFullYear(); + const month = String(post.createdAt.getMonth() + 1).padStart(2, '0'); + const day = String(post.createdAt.getDate()).padStart(2, '0'); + return `/${year}/${month}/${day}/${post.slug}`; } function getPageHtml(content: string, title: string): string { @@ -175,6 +292,7 @@ function getPageHtml(content: string, title: string): string { export class PreviewServer { private readonly postEngine: PostEngineContract; + private readonly mediaEngine: MediaEngineContract; private readonly settingsEngine: MetaEngineContract; private readonly getActiveProjectContext: () => Promise; private server: Server | null = null; @@ -182,6 +300,7 @@ export class PreviewServer { constructor(dependencies?: Partial) { this.postEngine = dependencies?.postEngine ?? getPostEngine(); + this.mediaEngine = dependencies?.mediaEngine ?? getMediaEngine(); this.settingsEngine = dependencies?.settingsEngine ?? getMetaEngine(); this.getActiveProjectContext = dependencies?.getActiveProjectContext ?? (async () => { const projectEngine = getProjectEngine(); @@ -278,10 +397,12 @@ export class PreviewServer { try { const context = await this.getActiveProjectContext(); this.postEngine.setProjectContext(context.projectId, context.dataDir); + this.mediaEngine.setProjectContext?.(context.projectId, context.dataDir, context.dataDir); this.settingsEngine.setProjectContext(context.projectId, context.dataDir); const metadata = await this.settingsEngine.getProjectMetadata(); const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage); + const htmlRewriteContext = await this.buildHtmlRewriteContext(); const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/'); @@ -292,7 +413,13 @@ export class PreviewServer { return; } - const result = await this.resolveRoute(pathname, maxPostsPerPage); + const mediaAsset = await this.resolveMediaAsset(pathname, context.dataDir); + if (mediaAsset) { + this.respondAsset(res, mediaAsset.contentType, mediaAsset.body); + return; + } + + const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext); if (!result) { this.respond(res, 404, 'Not Found'); return; @@ -305,24 +432,62 @@ export class PreviewServer { } } - private async resolveRoute(pathname: string, maxPostsPerPage: number): Promise { + private async resolveRoute(pathname: string, maxPostsPerPage: number, rewriteContext: HtmlRewriteContext): Promise { + const postsYearMonthSlugMatch = pathname.match(/^\/posts\/(\d{4})\/(\d{1,2})\/([^/]+)$/); + if (postsYearMonthSlugMatch) { + const year = Number(postsYearMonthSlugMatch[1]); + const month = Number(postsYearMonthSlugMatch[2]); + const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); + if (month < 1 || month > 12) return null; + const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 }); + if (!post) return null; + return this.renderPostList([post], rewriteContext); + } + + const postsSlugMatch = pathname.match(/^\/posts\/([^/]+)$/); + if (postsSlugMatch) { + const slug = postsSlugMatch[1].replace(/\.html?$/i, ''); + const post = await this.findPublishedPostBySlug(slug); + if (!post) return null; + return this.renderPostList([post], rewriteContext); + } + + const legacyPostsYearMonthSlugMatch = pathname.match(/^\/post\/(\d{4})\/(\d{1,2})\/([^/]+)$/); + if (legacyPostsYearMonthSlugMatch) { + const year = Number(legacyPostsYearMonthSlugMatch[1]); + const month = Number(legacyPostsYearMonthSlugMatch[2]); + const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, ''); + if (month < 1 || month > 12) return null; + const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 }); + if (!post) return null; + return this.renderPostList([post], rewriteContext); + } + + const legacyPostsSlugMatch = pathname.match(/^\/post\/([^/]+)$/); + if (legacyPostsSlugMatch) { + const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, ''); + const post = await this.findPublishedPostBySlug(slug); + if (!post) return null; + return this.renderPostList([post], rewriteContext); + } + if (pathname === '/') { const posts = await this.loadPublishedPosts({ status: 'published' }, maxPostsPerPage); - return this.renderPostList(posts); + return this.renderPostList(posts, rewriteContext); } const tagMatch = pathname.match(/^\/tag\/([^/]+)$/); if (tagMatch) { const tag = tagMatch[1]; const posts = await this.loadPublishedPosts({ status: 'published', tags: [tag] }, maxPostsPerPage); - return this.renderPostList(posts); + return this.renderPostList(posts, rewriteContext); } const categoryMatch = pathname.match(/^\/category\/([^/]+)$/); if (categoryMatch) { const category = categoryMatch[1]; const posts = await this.loadPublishedPosts({ status: 'published', categories: [category] }, maxPostsPerPage); - return this.renderPostList(posts); + return this.renderPostList(posts, rewriteContext); } const daySlugMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/); @@ -334,7 +499,7 @@ export class PreviewServer { const posts = await this.loadPostsForDay(year, month, day, maxPostsPerPage); const post = posts.find((candidate) => candidate.slug === slug) || null; if (!post) return null; - return this.renderPostList([post]); + return this.renderPostList([post], rewriteContext); } const dayMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})$/); @@ -343,7 +508,7 @@ export class PreviewServer { const month = Number(dayMatch[2]); const day = Number(dayMatch[3]); const posts = await this.loadPostsForDay(year, month, day, maxPostsPerPage); - return this.renderPostList(posts); + return this.renderPostList(posts, rewriteContext); } const monthMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})$/); @@ -352,14 +517,14 @@ export class PreviewServer { const month = Number(monthMatch[2]); if (month < 1 || month > 12) return null; const posts = await this.loadPublishedPosts({ status: 'published', year, month: month - 1 }, maxPostsPerPage); - return this.renderPostList(posts); + return this.renderPostList(posts, rewriteContext); } const yearMatch = pathname.match(/^\/(\d{4})$/); if (yearMatch) { const year = Number(yearMatch[1]); const posts = await this.loadPublishedPosts({ status: 'published', year }, maxPostsPerPage); - return this.renderPostList(posts); + return this.renderPostList(posts, rewriteContext); } const pageSlugMatch = pathname.match(/^\/([^/]+)$/); @@ -368,12 +533,27 @@ export class PreviewServer { const pages = await this.loadPublishedPosts({ status: 'published', categories: ['page'] }, maxPostsPerPage); const page = pages.find((candidate) => candidate.slug === slug) || null; if (!page) return null; - return this.renderPostList([page]); + return this.renderPostList([page], rewriteContext); } return null; } + private async findPublishedPostBySlug(slug: string, dateFilter?: { year: number; month: number }): Promise { + if (!slug) return null; + + const filter: PostFilter = { + status: 'published', + ...(dateFilter ? { year: dateFilter.year, month: dateFilter.month } : {}), + }; + + const candidates = await this.postEngine.getPostsFiltered(filter); + const match = candidates.find((candidate) => candidate.slug === slug); + if (!match) return null; + + return (await this.postEngine.getPost(match.id)) ?? match; + } + private async loadPostsForDay(year: number, month: number, day: number, maxPostsPerPage: number): Promise { if (month < 1 || month > 12 || day < 1 || day > 31) { return []; @@ -410,11 +590,43 @@ export class PreviewServer { return withContent; } - private async renderPostList(posts: PostData[]): Promise { - const rendered = await Promise.all(posts.map((post) => renderPostHtml(post))); + private async renderPostList(posts: PostData[], rewriteContext: HtmlRewriteContext): Promise { + const rendered = await Promise.all(posts.map((post) => renderPostHtml(post, rewriteContext))); return rendered.join('\n'); } + private async buildHtmlRewriteContext(): Promise { + const publishedPosts = await this.postEngine.getPostsFiltered({ status: 'published' }); + const canonicalPostPathBySlug = new Map(); + + for (const post of publishedPosts) { + canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post)); + } + + const canonicalMediaPathBySourcePath = new Map(); + try { + const mediaItems = await this.mediaEngine.getAllMedia(); + for (const media of mediaItems) { + const year = media.createdAt.getFullYear(); + const month = String(media.createdAt.getMonth() + 1).padStart(2, '0'); + const canonicalPath = `/media/${year}/${month}/${media.filename}`; + + const originalNameKey = `media/${year}/${month}/${media.originalName}`.toLowerCase(); + const filenameKey = `media/${year}/${month}/${media.filename}`.toLowerCase(); + + canonicalMediaPathBySourcePath.set(originalNameKey, canonicalPath); + canonicalMediaPathBySourcePath.set(filenameKey, canonicalPath); + } + } catch { + // Keep media map empty if media metadata cannot be loaded. + } + + return { + canonicalPostPathBySlug, + canonicalMediaPathBySourcePath, + }; + } + private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> { const match = pathname.match(/^\/assets\/([^/]+)$/); if (!match) return null; @@ -436,6 +648,65 @@ export class PreviewServer { } } + private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> { + const match = pathname.match(/^\/media\/(.+)$/); + if (!match || !dataDir) return null; + + const relativeMediaPath = path.posix.normalize(`media/${match[1]}`); + if (!relativeMediaPath.startsWith('media/')) { + return null; + } + + const absoluteDataDir = path.resolve(dataDir); + const mediaRoot = path.resolve(absoluteDataDir, 'media'); + const absoluteMediaPath = path.resolve(absoluteDataDir, relativeMediaPath); + + if (absoluteMediaPath !== mediaRoot && !absoluteMediaPath.startsWith(`${mediaRoot}${path.sep}`)) { + return null; + } + + try { + const body = await readFile(absoluteMediaPath); + return { + contentType: this.getMediaContentType(absoluteMediaPath), + body, + }; + } catch { + return null; + } + } + + private getMediaContentType(filePath: string): string { + const extension = path.extname(filePath).toLowerCase(); + switch (extension) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.svg': + return 'image/svg+xml'; + case '.bmp': + return 'image/bmp'; + case '.avif': + return 'image/avif'; + case '.mp4': + return 'video/mp4'; + case '.webm': + return 'video/webm'; + case '.mov': + return 'video/quicktime'; + case '.pdf': + return 'application/pdf'; + default: + return 'application/octet-stream'; + } + } + private respond(res: ServerResponse, status: number, body: string): void { res.statusCode = status; res.setHeader('Content-Type', 'text/html; charset=utf-8'); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 034a0e5..d3bddd1 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -475,10 +475,12 @@ export function registerIpcHandlers(): void { safeHandle('media:getUrl', async (_, id: string) => { // Returns the relative path for a media item (e.g. media/2025/01/uuid.jpg) - // This is the format used in markdown content for image references + // and exposes it as an absolute preview path (e.g. /media/2025/01/uuid.jpg) + // so inserted markdown uses root-absolute URLs. const engine = getMediaEngine(); const relativePath = await engine.getRelativePath(id); - return relativePath ?? `media/${id}`; + const normalized = relativePath ?? `media/${id}`; + return normalized.startsWith('/') ? normalized : `/${normalized}`; }); safeHandle('media:getFilePath', async (_, id: string) => { diff --git a/src/renderer/components/InsertModal/InsertModal.css b/src/renderer/components/InsertModal/InsertModal.css index de7bb1e..99456ff 100644 --- a/src/renderer/components/InsertModal/InsertModal.css +++ b/src/renderer/components/InsertModal/InsertModal.css @@ -203,6 +203,13 @@ justify-content: center; } +.insert-modal-footer-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + .insert-modal-hint { font-size: 11px; color: var(--color-text-muted, #888); @@ -210,6 +217,12 @@ letter-spacing: 0.5px; } +.insert-modal-format-hint { + font-size: 11px; + color: var(--color-text-muted, #888); + font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; +} + /* Scrollbar styling */ .insert-modal-results::-webkit-scrollbar { width: 8px; diff --git a/src/renderer/components/InsertModal/InsertModal.tsx b/src/renderer/components/InsertModal/InsertModal.tsx index 5d765dc..b5f7e9e 100644 --- a/src/renderer/components/InsertModal/InsertModal.tsx +++ b/src/renderer/components/InsertModal/InsertModal.tsx @@ -324,11 +324,20 @@ export const InsertModal: React.FC = ({ )}
- - {activeTab === 'internal' - ? 'Use ↑↓ to navigate, Enter to select, Esc to close' - : 'Enter URL and press Enter or click button, Esc to close'} - +
+ + {activeTab === 'internal' + ? 'Use ↑↓ to navigate, Enter to select, Esc to close' + : 'Enter URL and press Enter or click button, Esc to close'} + + {activeTab === 'internal' && ( + + {mode === 'link' + ? 'Canonical: /YYYY/MM/DD/slug' + : 'Canonical: /media/YYYY/MM/file.ext'} + + )} +
diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 787a793..8cdefac 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -1,4 +1,7 @@ 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'; @@ -84,13 +87,26 @@ function makeSettings(maxPostsPerPage = 50): SettingsEngineLike { }; } +function makeMediaEngine(mediaItems: Array<{ id: string; filename: string; originalName: string; createdAt: Date }>) { + return { + async getAllMedia() { + return mediaItems; + }, + }; +} + 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(() => { @@ -328,4 +344,91 @@ describe('PreviewServer', () => { 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('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'); + }); }); \ No newline at end of file diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 945a983..64b1639 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -918,21 +918,21 @@ describe('IPC Handlers', () => { }); describe('media:getUrl', () => { - it('should return relative media path', async () => { + it('should return absolute media path', async () => { mockMediaEngine.getRelativePath.mockResolvedValue('media/2025/01/media-123.jpg'); const result = await invokeHandler('media:getUrl', 'media-123'); expect(mockMediaEngine.getRelativePath).toHaveBeenCalledWith('media-123'); - expect(result).toBe('media/2025/01/media-123.jpg'); + expect(result).toBe('/media/2025/01/media-123.jpg'); }); - it('should fall back to media/{id} when relative path is not found', async () => { + it('should fall back to /media/{id} when relative path is not found', async () => { mockMediaEngine.getRelativePath.mockResolvedValue(null); const result = await invokeHandler('media:getUrl', 'media-unknown'); - expect(result).toBe('media/media-unknown'); + expect(result).toBe('/media/media-unknown'); }); }); diff --git a/tests/renderer/components/InsertModal.test.tsx b/tests/renderer/components/InsertModal.test.tsx new file mode 100644 index 0000000..cfed947 --- /dev/null +++ b/tests/renderer/components/InsertModal.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { InsertModal } from '../../../src/renderer/components/InsertModal/InsertModal'; + +describe('InsertModal format hints', () => { + it('shows canonical post link format hint in internal link mode', () => { + render( + + ); + + expect(screen.getByText('Canonical: /YYYY/MM/DD/slug')).toBeInTheDocument(); + }); + + it('shows canonical media format hint in internal image mode', () => { + render( + + ); + + expect(screen.getByText('Canonical: /media/YYYY/MM/file.ext')).toBeInTheDocument(); + }); +});