feat: basic preview server running
This commit is contained in:
@@ -20,6 +20,40 @@ export interface ProjectMetadata {
|
|||||||
dataPath?: string; // Custom path for project data
|
dataPath?: string; // Custom path for project data
|
||||||
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
|
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
|
||||||
defaultAuthor?: string; // Default author for new posts and media
|
defaultAuthor?: string; // Default author for new posts and media
|
||||||
|
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||||
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||||
|
const MAX_MAX_POSTS_PER_PAGE = 500;
|
||||||
|
|
||||||
|
function sanitizeMaxPostsPerPage(value: unknown): number | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = Math.floor(numeric);
|
||||||
|
if (rounded < MIN_MAX_POSTS_PER_PAGE) {
|
||||||
|
return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||||
|
}
|
||||||
|
if (rounded > MAX_MAX_POSTS_PER_PAGE) {
|
||||||
|
return MAX_MAX_POSTS_PER_PAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||||
|
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
maxPostsPerPage,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,7 +154,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* Set the project metadata (replaces existing).
|
* Set the project metadata (replaces existing).
|
||||||
*/
|
*/
|
||||||
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
|
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
|
||||||
this.projectMetadata = { ...metadata };
|
this.projectMetadata = normalizeProjectMetadata({ ...metadata });
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||||
}
|
}
|
||||||
@@ -129,16 +163,25 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* Update specific fields of project metadata.
|
* Update specific fields of project metadata.
|
||||||
*/
|
*/
|
||||||
async updateProjectMetadata(updates: Partial<ProjectMetadata>): Promise<void> {
|
async updateProjectMetadata(updates: Partial<ProjectMetadata>): Promise<void> {
|
||||||
|
const normalizedUpdates: Partial<ProjectMetadata> = { ...updates };
|
||||||
|
if (updates.maxPostsPerPage !== undefined) {
|
||||||
|
normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(updates.maxPostsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.projectMetadata) {
|
if (!this.projectMetadata) {
|
||||||
this.projectMetadata = {
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
name: updates.name || '',
|
name: normalizedUpdates.name || '',
|
||||||
description: updates.description,
|
description: normalizedUpdates.description,
|
||||||
};
|
dataPath: normalizedUpdates.dataPath,
|
||||||
|
mainLanguage: normalizedUpdates.mainLanguage,
|
||||||
|
defaultAuthor: normalizedUpdates.defaultAuthor,
|
||||||
|
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
this.projectMetadata = {
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
...this.projectMetadata,
|
...this.projectMetadata,
|
||||||
...updates,
|
...normalizedUpdates,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||||
@@ -228,7 +271,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
const filePath = this.getProjectMetadataFilePath();
|
const filePath = this.getProjectMetadataFilePath();
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
const parsed = JSON.parse(content) as ProjectMetadata;
|
const parsed = JSON.parse(content) as ProjectMetadata;
|
||||||
this.projectMetadata = parsed;
|
this.projectMetadata = normalizeProjectMetadata(parsed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
console.error('[MetaEngine] Failed to load project metadata:', error);
|
console.error('[MetaEngine] Failed to load project metadata:', error);
|
||||||
@@ -423,6 +466,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
name: projectData.name,
|
name: projectData.name,
|
||||||
description: projectData.description || undefined,
|
description: projectData.description || undefined,
|
||||||
dataPath: projectData.dataPath || undefined,
|
dataPath: projectData.dataPath || undefined,
|
||||||
|
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
|
||||||
};
|
};
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
}
|
}
|
||||||
|
|||||||
369
src/main/engine/PreviewServer.ts
Normal file
369
src/main/engine/PreviewServer.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
|
||||||
|
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
|
||||||
|
import { getProjectEngine } from './ProjectEngine';
|
||||||
|
|
||||||
|
interface ActiveProjectContext {
|
||||||
|
projectId: string;
|
||||||
|
dataDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostEngineContract {
|
||||||
|
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||||
|
getPost: (id: string) => Promise<PostData | null>;
|
||||||
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaEngineContract {
|
||||||
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||||
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewServerDependencies {
|
||||||
|
postEngine: PostEngineContract;
|
||||||
|
settingsEngine: MetaEngineContract;
|
||||||
|
getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||||
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||||
|
const MAX_MAX_POSTS_PER_PAGE = 500;
|
||||||
|
|
||||||
|
function clampMaxPostsPerPage(value: unknown): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = Math.floor(value);
|
||||||
|
if (normalized < MIN_MAX_POSTS_PER_PAGE) return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||||
|
if (normalized > MAX_MAX_POSTS_PER_PAGE) return MAX_MAX_POSTS_PER_PAGE;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMacroParams(paramString: string | undefined): Record<string, string> {
|
||||||
|
if (!paramString) return {};
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
const regex = /(\w+)=(?:["']([^"']*?)["']|([^\s\]]+))/g;
|
||||||
|
let match: RegExpExecArray | null = null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(paramString)) !== null) {
|
||||||
|
params[match[1]] = match[2] !== undefined ? match[2] : match[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMacro(name: string, params: Record<string, string>, postId: string): string {
|
||||||
|
if (name === 'youtube') {
|
||||||
|
const id = escapeHtml(params.id || '');
|
||||||
|
const title = escapeHtml(params.title || 'YouTube video');
|
||||||
|
if (!id) return '';
|
||||||
|
return `<div class="macro-youtube"><iframe src="https://www.youtube.com/embed/${id}?rel=0" title="${title}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'vimeo') {
|
||||||
|
const id = escapeHtml(params.id || '');
|
||||||
|
const title = escapeHtml(params.title || 'Vimeo video');
|
||||||
|
if (!id) return '';
|
||||||
|
return `<div class="macro-vimeo"><iframe src="https://player.vimeo.com/video/${id}" title="${title}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'gallery') {
|
||||||
|
const columns = escapeHtml(params.columns || '3');
|
||||||
|
const caption = params.caption ? `<figcaption>${escapeHtml(params.caption)}</figcaption>` : '';
|
||||||
|
return `<div class="macro-gallery" data-post-id="${escapeHtml(postId)}" data-columns="${columns}"><div>Gallery preview is not interactive yet.</div>${caption}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'photo_archive') {
|
||||||
|
const year = params.year ? ` data-year="${escapeHtml(params.year)}"` : '';
|
||||||
|
const month = params.month ? ` data-month="${escapeHtml(params.month)}"` : '';
|
||||||
|
return `<div class="macro-photo-archive"${year}${month}><div>Photo archive preview is not interactive yet.</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPostHtml(post: PostData): 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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageHtml(content: string, title: string): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||||
|
main { display: grid; gap: 1rem; }
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
${content}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PreviewServer {
|
||||||
|
private readonly postEngine: PostEngineContract;
|
||||||
|
private readonly settingsEngine: MetaEngineContract;
|
||||||
|
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
||||||
|
private server: Server | null = null;
|
||||||
|
private port: number | null = null;
|
||||||
|
|
||||||
|
constructor(dependencies?: Partial<PreviewServerDependencies>) {
|
||||||
|
this.postEngine = dependencies?.postEngine ?? getPostEngine();
|
||||||
|
this.settingsEngine = dependencies?.settingsEngine ?? getMetaEngine();
|
||||||
|
this.getActiveProjectContext = dependencies?.getActiveProjectContext ?? (async () => {
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
|
const projectId = activeProject?.id ?? 'default';
|
||||||
|
const dataDir = projectEngine.getDataDir(projectId, activeProject?.dataPath);
|
||||||
|
return { projectId, dataDir };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(preferredPort = 0): Promise<number> {
|
||||||
|
if (this.server && this.port !== null) {
|
||||||
|
return this.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server = createServer(async (req, res) => {
|
||||||
|
await this.handleRequest(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (!this.server) {
|
||||||
|
reject(new Error('Preview server was not created'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server.once('error', reject);
|
||||||
|
this.server.listen(preferredPort, '127.0.0.1', () => {
|
||||||
|
this.server?.off('error', reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = this.server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
throw new Error('Failed to get preview server address');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.port = address.port;
|
||||||
|
return this.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.server) {
|
||||||
|
this.port = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (!this.server) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server = null;
|
||||||
|
this.port = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseUrl(): string {
|
||||||
|
if (this.port === null) {
|
||||||
|
throw new Error('Preview server not started');
|
||||||
|
}
|
||||||
|
return `http://127.0.0.1:${this.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
const remoteAddress = req.socket.remoteAddress;
|
||||||
|
const isLocal = remoteAddress === '127.0.0.1'
|
||||||
|
|| remoteAddress === '::1'
|
||||||
|
|| remoteAddress === '::ffff:127.0.0.1';
|
||||||
|
|
||||||
|
if (!isLocal) {
|
||||||
|
this.respond(res, 403, 'Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
||||||
|
this.respond(res, 405, 'Method Not Allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await this.getActiveProjectContext();
|
||||||
|
this.postEngine.setProjectContext(context.projectId, context.dataDir);
|
||||||
|
this.settingsEngine.setProjectContext(context.projectId, context.dataDir);
|
||||||
|
|
||||||
|
const metadata = await this.settingsEngine.getProjectMetadata();
|
||||||
|
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
||||||
|
|
||||||
|
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
|
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
||||||
|
|
||||||
|
const result = await this.resolveRoute(pathname, maxPostsPerPage);
|
||||||
|
if (!result) {
|
||||||
|
this.respond(res, 404, 'Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.respond(res, 200, getPageHtml(result, 'Blog Preview'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PreviewServer] Request failed:', error);
|
||||||
|
this.respond(res, 500, 'Internal Server Error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRoute(pathname: string, maxPostsPerPage: number): Promise<string | null> {
|
||||||
|
if (pathname === '/') {
|
||||||
|
const posts = await this.loadPublishedPosts({ status: 'published' }, maxPostsPerPage);
|
||||||
|
return this.renderPostList(posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daySlugMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/);
|
||||||
|
if (daySlugMatch) {
|
||||||
|
const year = Number(daySlugMatch[1]);
|
||||||
|
const month = Number(daySlugMatch[2]);
|
||||||
|
const day = Number(daySlugMatch[3]);
|
||||||
|
const slug = daySlugMatch[4];
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
|
||||||
|
if (dayMatch) {
|
||||||
|
const year = Number(dayMatch[1]);
|
||||||
|
const month = Number(dayMatch[2]);
|
||||||
|
const day = Number(dayMatch[3]);
|
||||||
|
const posts = await this.loadPostsForDay(year, month, day, maxPostsPerPage);
|
||||||
|
return this.renderPostList(posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthMatch = pathname.match(/^\/(\d{4})\/(\d{1,2})$/);
|
||||||
|
if (monthMatch) {
|
||||||
|
const year = Number(monthMatch[1]);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSlugMatch = pathname.match(/^\/([^/]+)$/);
|
||||||
|
if (pageSlugMatch) {
|
||||||
|
const slug = pageSlugMatch[1];
|
||||||
|
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 null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPostsForDay(year: number, month: number, day: number, maxPostsPerPage: number): Promise<PostData[]> {
|
||||||
|
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const posts = await this.loadPublishedPosts({
|
||||||
|
status: 'published',
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}, maxPostsPerPage);
|
||||||
|
|
||||||
|
return posts.filter((post) => {
|
||||||
|
const createdAt = post.createdAt;
|
||||||
|
return createdAt.getFullYear() === year
|
||||||
|
&& createdAt.getMonth() === month - 1
|
||||||
|
&& createdAt.getDate() === day;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPublishedPosts(filter: PostFilter, maxPostsPerPage: number): Promise<PostData[]> {
|
||||||
|
const posts = await this.postEngine.getPostsFiltered(filter);
|
||||||
|
const limited = posts.slice(0, maxPostsPerPage);
|
||||||
|
|
||||||
|
const withContent = await Promise.all(
|
||||||
|
limited.map(async (post) => {
|
||||||
|
const fullPost = await this.postEngine.getPost(post.id);
|
||||||
|
return fullPost ?? post;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return withContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderPostList(posts: PostData[]): Promise<string> {
|
||||||
|
const rendered = await Promise.all(posts.map((post) => renderPostHtml(post)));
|
||||||
|
return rendered.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private respond(res: ServerResponse, status: number, body: string): void {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -699,7 +699,7 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getProjectMetadata();
|
return engine.getProjectMetadata();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string }) => {
|
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
await engine.updateProjectMetadata(updates);
|
await engine.updateProjectMetadata(updates);
|
||||||
return engine.getProjectMetadata();
|
return engine.getProjectMetadata();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net } from 'electron';
|
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net, shell } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { getDatabase } from './database';
|
import { getDatabase } from './database';
|
||||||
@@ -6,8 +6,11 @@ import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, clea
|
|||||||
import { media } from './database/schema';
|
import { media } from './database/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getMediaEngine } from './engine/MediaEngine';
|
import { getMediaEngine } from './engine/MediaEngine';
|
||||||
|
import { PreviewServer } from './engine/PreviewServer';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
let previewServer: PreviewServer | null = null;
|
||||||
|
const PREVIEW_SERVER_PORT = 4123;
|
||||||
|
|
||||||
// Check if dev server is likely running (only in development)
|
// Check if dev server is likely running (only in development)
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
@@ -97,6 +100,15 @@ function createWindow(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openPreviewInBrowser(): Promise<void> {
|
||||||
|
if (!previewServer) {
|
||||||
|
previewServer = new PreviewServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
await previewServer.start(PREVIEW_SERVER_PORT);
|
||||||
|
await shell.openExternal(`${previewServer.getBaseUrl()}/`);
|
||||||
|
}
|
||||||
|
|
||||||
function createApplicationMenu(): Menu {
|
function createApplicationMenu(): Menu {
|
||||||
const template: MenuItemConstructorOptions[] = [
|
const template: MenuItemConstructorOptions[] = [
|
||||||
{
|
{
|
||||||
@@ -125,10 +137,20 @@ function createApplicationMenu(): Menu {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Open in Browser',
|
||||||
|
click: async () => {
|
||||||
|
try {
|
||||||
|
await openPreviewInBrowser();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open preview in browser:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Open Data Folder',
|
label: 'Open Data Folder',
|
||||||
click: async () => {
|
click: async () => {
|
||||||
const { shell } = require('electron');
|
|
||||||
const paths = getDatabase().getDataPaths();
|
const paths = getDatabase().getDataPaths();
|
||||||
shell.openPath(path.dirname(paths.database));
|
shell.openPath(path.dirname(paths.database));
|
||||||
},
|
},
|
||||||
@@ -275,14 +297,12 @@ function createApplicationMenu(): Menu {
|
|||||||
{
|
{
|
||||||
label: 'View on GitHub',
|
label: 'View on GitHub',
|
||||||
click: async () => {
|
click: async () => {
|
||||||
const { shell } = require('electron');
|
|
||||||
await shell.openExternal('https://github.com/rfc1437/bDS');
|
await shell.openExternal('https://github.com/rfc1437/bDS');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Report Issue',
|
label: 'Report Issue',
|
||||||
click: async () => {
|
click: async () => {
|
||||||
const { shell } = require('electron');
|
|
||||||
await shell.openExternal('https://github.com/rfc1437/bDS/issues');
|
await shell.openExternal('https://github.com/rfc1437/bDS/issues');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -444,6 +464,11 @@ app.on('before-quit', async () => {
|
|||||||
// Cleanup chat resources
|
// Cleanup chat resources
|
||||||
await cleanupChatHandlers();
|
await cleanupChatHandlers();
|
||||||
|
|
||||||
|
if (previewServer) {
|
||||||
|
await previewServer.stop();
|
||||||
|
previewServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
|
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
|
||||||
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
||||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
|
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tag Management (advanced tag operations)
|
// Tag Management (advanced tag operations)
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface ProjectMetadata {
|
|||||||
description?: string;
|
description?: string;
|
||||||
dataPath?: string;
|
dataPath?: string;
|
||||||
mainLanguage?: string;
|
mainLanguage?: string;
|
||||||
|
defaultAuthor?: string;
|
||||||
|
maxPostsPerPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
@@ -517,7 +519,7 @@ export interface ElectronAPI {
|
|||||||
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
||||||
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string }) => Promise<ProjectMetadata | null>;
|
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise<ProjectMetadata | null>;
|
||||||
};
|
};
|
||||||
tags: {
|
tags: {
|
||||||
getAll: () => Promise<TagData[]>;
|
getAll: () => Promise<TagData[]>;
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [defaultProjectPath, setDefaultProjectPath] = useState('');
|
const [defaultProjectPath, setDefaultProjectPath] = useState('');
|
||||||
const [projectMainLanguage, setProjectMainLanguage] = useState('en');
|
const [projectMainLanguage, setProjectMainLanguage] = useState('en');
|
||||||
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
|
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
|
||||||
|
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
|
||||||
|
|
||||||
// Post categories management
|
// Post categories management
|
||||||
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
|
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
|
||||||
@@ -154,6 +155,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setProjectDefaultAuthor('');
|
setProjectDefaultAuthor('');
|
||||||
}
|
}
|
||||||
|
const maxPostsPerPage = typeof metadata?.maxPostsPerPage === 'number'
|
||||||
|
? metadata.maxPostsPerPage
|
||||||
|
: 50;
|
||||||
|
setProjectMaxPostsPerPage(maxPostsPerPage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [activeProject]);
|
}, [activeProject]);
|
||||||
@@ -253,6 +258,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
dataPath: projectDataPath.trim() || undefined,
|
dataPath: projectDataPath.trim() || undefined,
|
||||||
mainLanguage: projectMainLanguage,
|
mainLanguage: projectMainLanguage,
|
||||||
defaultAuthor: projectDefaultAuthor.trim() || undefined,
|
defaultAuthor: projectDefaultAuthor.trim() || undefined,
|
||||||
|
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
showToast.success('Project settings saved');
|
showToast.success('Project settings saved');
|
||||||
@@ -274,7 +280,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Keywords for each section for search filtering
|
// Keywords for each section for search filtering
|
||||||
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default'];
|
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page'];
|
||||||
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
|
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
|
||||||
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
|
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
|
||||||
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
|
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
|
||||||
@@ -387,6 +393,28 @@ export const SettingsView: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
id="project-max-posts-per-page"
|
||||||
|
label="Max Posts Per Page"
|
||||||
|
description="Maximum number of posts shown per preview route page."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="project-max-posts-per-page"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
value={projectMaxPostsPerPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = Number(e.target.value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
setProjectMaxPostsPerPage(50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProjectMaxPostsPerPage(Math.min(500, Math.max(1, Math.floor(parsed))));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
<div className="setting-actions">
|
<div className="setting-actions">
|
||||||
<button className="primary" onClick={handleSaveProject}>
|
<button className="primary" onClick={handleSaveProject}>
|
||||||
Save Project Settings
|
Save Project Settings
|
||||||
|
|||||||
@@ -565,6 +565,30 @@ describe('MetaEngine', () => {
|
|||||||
expect(metadata?.defaultAuthor).toBe('Loaded Author');
|
expect(metadata?.defaultAuthor).toBe('Loaded Author');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set and get maxPostsPerPage in project metadata', async () => {
|
||||||
|
await metaEngine.setProjectMetadata({
|
||||||
|
name: 'My Blog',
|
||||||
|
maxPostsPerPage: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = await metaEngine.getProjectMetadata();
|
||||||
|
expect(metadata?.maxPostsPerPage).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize invalid maxPostsPerPage values from filesystem', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||||
|
mockFiles.set(projectPath, JSON.stringify({
|
||||||
|
name: 'Loaded Project',
|
||||||
|
maxPostsPerPage: -5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await metaEngine.loadProjectMetadata();
|
||||||
|
|
||||||
|
const metadata = await metaEngine.getProjectMetadata();
|
||||||
|
expect(metadata?.maxPostsPerPage).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle ENOENT error when loading categories (no file)', async () => {
|
it('should handle ENOENT error when loading categories (no file)', async () => {
|
||||||
// No file exists, should not throw
|
// No file exists, should not throw
|
||||||
await metaEngine.loadCategories();
|
await metaEngine.loadCategories();
|
||||||
|
|||||||
247
tests/engine/PreviewServer.test.ts
Normal file
247
tests/engine/PreviewServer.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { PostData, PostFilter } from '../../src/main/engine/PostEngine';
|
||||||
|
import { PreviewServer } from '../../src/main/engine/PreviewServer';
|
||||||
|
|
||||||
|
type PostEngineLike = {
|
||||||
|
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||||
|
getPost: (id: string) => Promise<PostData | null>;
|
||||||
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsEngineLike = {
|
||||||
|
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number } | null>;
|
||||||
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makePost(overrides: Partial<PostData> = {}): 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 getPost(id: string): Promise<PostData | null> {
|
||||||
|
return byId.get(id) ?? null;
|
||||||
|
},
|
||||||
|
async getPostsFiltered(filter: PostFilter): Promise<PostData[]> {
|
||||||
|
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.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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PreviewServer', () => {
|
||||||
|
let server: PreviewServer;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<div class="post">');
|
||||||
|
expect(html).toContain('<h1>Newest</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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(/<div class="post">/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('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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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(/<div class="post">/g) || []).length;
|
||||||
|
expect(renderedPosts).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,18 @@ import { SettingsView } from '../../../src/renderer/components/SettingsView/Sett
|
|||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
describe('SettingsView Diff Preferences', () => {
|
describe('SettingsView Diff Preferences', () => {
|
||||||
|
let updateProjectMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
updateProjectMock = vi.fn().mockResolvedValue({
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Test Project',
|
||||||
|
slug: 'test-project',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
activeProject: {
|
activeProject: {
|
||||||
@@ -33,7 +43,8 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
meta: {
|
meta: {
|
||||||
...(window as any).electronAPI?.meta,
|
...(window as any).electronAPI?.meta,
|
||||||
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
||||||
getProjectMetadata: vi.fn().mockResolvedValue({}),
|
getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75 }),
|
||||||
|
updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12 }),
|
||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
...(window as any).electronAPI?.chat,
|
...(window as any).electronAPI?.chat,
|
||||||
@@ -43,7 +54,7 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
...(window as any).electronAPI?.projects,
|
...(window as any).electronAPI?.projects,
|
||||||
update: vi.fn().mockResolvedValue(null),
|
update: updateProjectMock,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -66,4 +77,19 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
hideUnchangedRegions: true,
|
hideUnchangedRegions: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes project-level max posts per page in metadata save payload', async () => {
|
||||||
|
render(<SettingsView />);
|
||||||
|
|
||||||
|
await screen.findByDisplayValue('75');
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save project settings/i });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ maxPostsPerPage: 75 })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user