fix: URL rewritings for publishing / preview

This commit is contained in:
2026-02-16 21:55:03 +01:00
parent 54a8ba5ceb
commit e98379fe95
8 changed files with 469 additions and 25 deletions

View File

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

View File

@@ -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<ActiveProjectContext>;
}
interface HtmlRewriteContext {
canonicalPostPathBySlug: Map<string, string>;
canonicalMediaPathBySourcePath: Map<string, string>;
}
interface MediaEngineContract {
getAllMedia: () => Promise<MediaData[]>;
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<string, strin
return params;
}
function isExternalOrSpecialUrl(value: string): boolean {
const normalized = value.trim();
if (!normalized) return false;
if (normalized.startsWith('#') || normalized.startsWith('//')) return true;
return /^[a-z][a-z0-9+.-]*:/i.test(normalized);
}
function splitPathSuffix(value: string): { pathPart: string; suffix: string } {
const match = value.match(/^([^?#]*)([?#].*)?$/);
return {
pathPart: match?.[1] ?? value,
suffix: match?.[2] ?? '',
};
}
function normalizePreviewHref(rawHref: string, rewriteContext: HtmlRewriteContext): string {
if (!rawHref || isExternalOrSpecialUrl(rawHref)) {
return rawHref;
}
const { pathPart, suffix } = splitPathSuffix(rawHref.trim());
const canonicalDayRouteMatch = pathPart.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([a-z0-9-]+)(?:\.html?)?$/i);
if (canonicalDayRouteMatch) {
const [, year, month, day, slug] = canonicalDayRouteMatch;
const normalizedMonth = String(Number(month)).padStart(2, '0');
const normalizedDay = String(Number(day)).padStart(2, '0');
return `/${year}/${normalizedMonth}/${normalizedDay}/${slug}${suffix}`;
}
const postBySlugMatch = pathPart.match(/^\/?post\/([a-z0-9-]+(?:\.html?)?)$/i);
if (postBySlugMatch) {
const slug = postBySlugMatch[1].replace(/\.html?$/i, '');
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
return `${canonical ?? `/posts/${slug}`}${suffix}`;
}
const postByYearMonthSlugMatch = pathPart.match(/^\/?post\/(\d{4})\/(\d{1,2})\/([a-z0-9-]+(?:\.html?)?)$/i);
if (postByYearMonthSlugMatch) {
const [, , , rawSlug] = postByYearMonthSlugMatch;
const slug = rawSlug.replace(/\.html?$/i, '');
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
return `${canonical ?? `/posts/${slug}`}${suffix}`;
}
const postsBySlugMatch = pathPart.match(/^\/?posts\/([a-z0-9-]+(?:\.html?)?)$/i);
if (postsBySlugMatch) {
const slug = postsBySlugMatch[1].replace(/\.html?$/i, '');
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
return `${canonical ?? `/posts/${slug}`}${suffix}`;
}
const postsByYearMonthSlugMatch = pathPart.match(/^\/?posts\/(\d{4})\/(\d{1,2})\/([a-z0-9-]+(?:\.html?)?)$/i);
if (postsByYearMonthSlugMatch) {
const [, , , rawSlug] = postsByYearMonthSlugMatch;
const slug = rawSlug.replace(/\.html?$/i, '');
const canonical = rewriteContext.canonicalPostPathBySlug.get(slug);
return `${canonical ?? `/posts/${slug}`}${suffix}`;
}
return rawHref;
}
function normalizePreviewSrc(rawSrc: string, rewriteContext: HtmlRewriteContext): string {
if (!rawSrc || isExternalOrSpecialUrl(rawSrc)) {
return rawSrc;
}
const { pathPart, suffix } = splitPathSuffix(rawSrc.trim());
const mediaMatch = pathPart.match(/^\/?media\/(\d{4})\/(\d{2})\/([^\s]+)$/i);
if (!mediaMatch) {
return rawSrc;
}
const [, year, month, filename] = mediaMatch;
const sourceKey = `media/${year}/${month}/${filename}`.toLowerCase();
const canonicalPath = rewriteContext.canonicalMediaPathBySourcePath.get(sourceKey);
if (canonicalPath) {
return `${canonicalPath}${suffix}`;
}
return `/media/${year}/${month}/${filename}${suffix}`;
}
function rewriteRenderedHtmlUrls(html: string, rewriteContext: HtmlRewriteContext): string {
return html
.replace(/\bhref=(['"])(.*?)\1/gi, (_fullMatch, quote: string, href: string) => {
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<string, string>, postId: string): string {
if (name === 'youtube') {
const id = escapeHtml(params.id || '');
@@ -136,14 +245,22 @@ function renderMacro(name: string, params: Record<string, string>, postId: strin
return '';
}
async function renderPostHtml(post: PostData): Promise<string> {
async function renderPostHtml(post: PostData, rewriteContext: HtmlRewriteContext): Promise<string> {
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 `<div class="post">${markdownHtml}</div>`;
const rewrittenHtml = rewriteRenderedHtmlUrls(markdownHtml, rewriteContext);
return `<div class="post">${rewrittenHtml}</div>`;
}
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<ActiveProjectContext>;
private server: Server | null = null;
@@ -182,6 +300,7 @@ export class PreviewServer {
constructor(dependencies?: Partial<PreviewServerDependencies>) {
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<string | null> {
private async resolveRoute(pathname: string, maxPostsPerPage: number, rewriteContext: HtmlRewriteContext): Promise<string | null> {
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<PostData | null> {
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<PostData[]> {
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<string> {
const rendered = await Promise.all(posts.map((post) => renderPostHtml(post)));
private async renderPostList(posts: PostData[], rewriteContext: HtmlRewriteContext): Promise<string> {
const rendered = await Promise.all(posts.map((post) => renderPostHtml(post, rewriteContext)));
return rendered.join('\n');
}
private async buildHtmlRewriteContext(): Promise<HtmlRewriteContext> {
const publishedPosts = await this.postEngine.getPostsFiltered({ status: 'published' });
const canonicalPostPathBySlug = new Map<string, string>();
for (const post of publishedPosts) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
}
const canonicalMediaPathBySourcePath = new Map<string, string>();
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');

View File

@@ -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) => {

View File

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

View File

@@ -324,11 +324,20 @@ export const InsertModal: React.FC<InsertModalProps> = ({
)}
<div className="insert-modal-footer">
<span className="insert-modal-hint">
{activeTab === 'internal'
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
: 'Enter URL and press Enter or click button, Esc to close'}
</span>
<div className="insert-modal-footer-content">
<span className="insert-modal-hint">
{activeTab === 'internal'
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
: 'Enter URL and press Enter or click button, Esc to close'}
</span>
{activeTab === 'internal' && (
<span className="insert-modal-format-hint">
{mode === 'link'
? 'Canonical: /YYYY/MM/DD/slug'
: 'Canonical: /media/YYYY/MM/file.ext'}
</span>
)}
</div>
</div>
</div>
</div>

View File

@@ -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('<title>Configured Project Name</title>');
expect(html).not.toContain('<title>Blog Preview</title>');
});
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');
});
});

View File

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

View File

@@ -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(
<InsertModal
mode="link"
onInsertLink={vi.fn()}
onInsertImage={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('Canonical: /YYYY/MM/DD/slug')).toBeInTheDocument();
});
it('shows canonical media format hint in internal image mode', () => {
render(
<InsertModal
mode="image"
onInsertLink={vi.fn()}
onInsertImage={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('Canonical: /media/YYYY/MM/file.ext')).toBeInTheDocument();
});
});