feat: first cut at the full renderer

This commit is contained in:
2026-02-20 17:54:04 +01:00
parent 22cb63e0a7
commit 3bbc5281e8
25 changed files with 4989 additions and 976 deletions

View File

@@ -4,7 +4,8 @@
"Bash(npm run build:*)",
"Bash(npx tsc:*)",
"Bash(node ./node_modules/typescript/bin/tsc:*)",
"Bash(npm run build:main:*)"
"Bash(npm run build:main:*)",
"Bash(npx vitest:*)"
]
}
}

776
CLAUDE.md Normal file
View File

@@ -0,0 +1,776 @@
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
## Project Overview
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
- **Electron** v28+ for cross-platform desktop
- **TypeScript** for all code (strict mode)
- **React** for the renderer UI
- **Drizzle ORM** for type-safe database access
- **@libsql/client** for SQLite (local database)
- **Zustand** for React state management
---
## ⚠️ MANDATORY: Test-First Development
**STOP!** Before writing ANY implementation code, you MUST:
1. **Write a failing test first** that describes the expected behavior
2. **Run the test** to confirm it fails (Red)
3. **Write minimal code** to make the test pass (Green)
4. **Refactor** while keeping tests green
> **No code without tests. No exceptions.**
>
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
> Mock only external dependencies (database, filesystem), never the class under test.
See the [TDD Requirements](#test-driven-development-tdd-requirements) section for detailed guidelines.
---
## ⚠️ MANDATORY: Fix All Test Failures
**You MUST investigate and fix ALL test failures before completing any task.**
- Never leave tests failing, even if they appear unrelated to your changes
- If a test failure is pre-existing, fix it as part of your current work
- Run the full test suite (`npm test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution
> **Zero failing tests. No exceptions.**
---
## ⚠️ MANDATORY: Remove Unused Code
**Never keep unused code around. Always delete it completely.**
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
- Do NOT comment out code "for later" - use version control history
- Do NOT skip tests for removed functionality - delete them
- Do NOT leave dead code paths, unused imports, or orphaned functions
- When refactoring, actively look for and remove any code that becomes unused
> **Delete unused code immediately. No exceptions.**
---
## ⚠️ MANDATORY: Build Verification After Code Changes
**You MUST run the full build after making code changes.**
- Run `npm run build` after any code modifications
- Fix ALL build errors before considering the task complete
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
- The build must complete successfully before the task is complete
> **Successful build required. No exceptions.**
---
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
- Preview HTML must reference only local/package-bundled assets
- Generated HTML must not include CDN-hosted JS/CSS libraries
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
- Avoid introducing any new `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
---
## Architecture Principles
### Separation of Concerns
1. **Engine Classes** (`src/main/engine/`): All business logic lives here
- `PostEngine`: Blog post CRUD, file I/O, markdown handling
- `MediaEngine`: Media import, metadata management
- `SyncEngine`: Remote database synchronization
- `TaskManager`: Async task queue with progress tracking
2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer
- Handlers call engine methods, never contain business logic
- Always use `ipcMain.handle` for async operations
3. **UI Components** (`src/renderer/components/`): Presentation only
- Components should be stateless where possible
- Use Zustand store for shared state
- Never call IPC directly from deeply nested components
### File Storage Pattern
- **Posts**: Markdown files with YAML frontmatter in `posts/` directory
- **Media**: Binary files with `.meta` JSON sidecar files in `media/` directory
- **Database**: Index/cache only, source of truth is filesystem
## TypeScript Best Practices
### Type Safety
```typescript
// ✅ DO: Use explicit return types for public methods
async function getPost(id: string): Promise<PostData | null> {
// ...
}
// ✅ DO: Use discriminated unions for status types
type SyncStatus = 'pending' | 'syncing' | 'synced' | 'error';
// ✅ DO: Use Drizzle's inferred types
import { posts, type Post, type NewPost } from './schema';
// ❌ DON'T: Use `any` type
function processData(data: any) { } // Bad
// ❌ DON'T: Ignore null/undefined possibilities
const post = await getPost(id);
console.log(post.title); // Bad - post might be null
```
### Async/Await Patterns
```typescript
// ✅ DO: Use try-catch with specific error handling
try {
await fileOperation();
} catch (error) {
if (error instanceof Error && error.message.includes('ENOENT')) {
// Handle file not found
}
throw error;
}
// ✅ DO: Use Promise.all for concurrent operations
const [posts, media] = await Promise.all([
postEngine.getAllPosts(),
mediaEngine.getAllMedia()
]);
// ❌ DON'T: Forget to await async operations
savePost(post); // Bad - fire and forget
```
### Error Handling
```typescript
// ✅ DO: Create typed error classes
class SyncError extends Error {
constructor(
message: string,
public readonly code: 'AUTH_FAILED' | 'NETWORK_ERROR' | 'CONFLICT'
) {
super(message);
this.name = 'SyncError';
}
}
// ✅ DO: Return result objects for operations that can fail
interface OperationResult<T> {
success: boolean;
data?: T;
error?: string;
}
```
## Electron Best Practices
### IPC Communication
```typescript
// ✅ DO: Use contextBridge for secure IPC exposure
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
posts: {
getAll: () => ipcRenderer.invoke('posts:getAll'),
create: (data: CreatePostInput) => ipcRenderer.invoke('posts:create', data),
}
});
// ✅ DO: Validate all IPC inputs in main process
ipcMain.handle('posts:create', async (_event, data: unknown) => {
const validated = validateCreatePostInput(data);
return postEngine.createPost(validated);
});
// ❌ DON'T: Use nodeIntegration: true
// ❌ DON'T: Expose Node.js APIs directly to renderer
```
### Window Management
```typescript
// ✅ DO: Check window existence before sending messages
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('event', data);
}
// ✅ DO: Clean up IPC handlers on window close
mainWindow.on('closed', () => {
ipcMain.removeHandler('posts:getAll');
});
```
### Process Separation
```typescript
// Main process: File system, database, native APIs
// Renderer process: UI only, no direct fs/db access
// ✅ DO: All file operations in main process
// src/main/engine/PostEngine.ts
await fs.writeFile(filePath, content);
// ❌ DON'T: Import 'fs' in renderer
// src/renderer/components/Editor.tsx
import fs from 'fs'; // Bad!
```
## SQLite & Drizzle ORM Best Practices
### Schema Definition
```typescript
// ✅ DO: Use proper column types and constraints
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
syncStatus: text('sync_status').$type<'pending' | 'synced'>().default('pending'),
});
// ✅ DO: Create indexes for frequently queried columns
// CREATE INDEX idx_posts_slug ON posts(slug);
// CREATE INDEX idx_posts_sync_status ON posts(sync_status);
```
### Query Patterns
```typescript
import { eq, and, desc, sql } from 'drizzle-orm';
// ✅ DO: Use Drizzle query builder
const drafts = await db
.select()
.from(posts)
.where(eq(posts.status, 'draft'))
.orderBy(desc(posts.updatedAt));
// ✅ DO: Use transactions for multi-table updates
await db.transaction(async (tx) => {
await tx.update(posts).set({ status: 'published' }).where(eq(posts.id, id));
await tx.insert(syncLog).values({ entityId: id, operation: 'update' });
});
// ❌ DON'T: Write raw SQL unless absolutely necessary
// ❌ DON'T: Use string interpolation in queries
const result = db.run(`SELECT * FROM posts WHERE id = '${id}'`); // SQL injection risk!
```
### Connection Management
```typescript
// ✅ DO: Use singleton pattern for database connection
let dbInstance: DatabaseConnection | null = null;
export function getDatabase(): DatabaseConnection {
if (!dbInstance) {
dbInstance = new DatabaseConnection();
}
return dbInstance;
}
// ✅ DO: Close connection gracefully on app quit
app.on('before-quit', async () => {
await getDatabase().close();
});
```
## Remote Sync Best Practices (Dropbox)
### Sync Strategy
```typescript
// ✅ DO: Track sync status per entity
interface SyncableEntity {
syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict';
syncedAt: number | null;
checksum: string;
}
// ✅ DO: Use checksums to detect conflicts
const localChecksum = calculateChecksum(localContent);
const remoteChecksum = await fetchRemoteChecksum(id);
if (localChecksum !== remoteChecksum) {
// Handle conflict
}
// ✅ DO: Implement retry logic with exponential backoff
async function syncWithRetry(maxAttempts = 3): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await sync();
return;
} catch (error) {
if (attempt === maxAttempts) throw error;
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
```
### Offline-First Approach
```typescript
// ✅ DO: Always write to local first
async function savePost(post: PostData): Promise<void> {
// 1. Save to local file system
await writePostFile(post);
// 2. Update local database
await updateLocalDatabase(post);
// 3. Mark for sync (don't wait for network)
await markForSync(post.id);
}
// ✅ DO: Queue sync operations
const syncQueue: SyncOperation[] = [];
function queueSync(operation: SyncOperation): void {
syncQueue.push(operation);
processSyncQueue(); // Non-blocking
}
// ✅ DO: Handle network state changes
window.addEventListener('online', () => {
syncEngine.processPendingChanges();
});
```
### Conflict Resolution
```typescript
// ✅ DO: Implement clear conflict resolution strategy
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
interface ConflictInfo {
entityId: string;
localVersion: EntityVersion;
remoteVersion: EntityVersion;
resolution?: ConflictResolution;
}
// ✅ DO: Allow user to resolve conflicts manually when needed
async function resolveConflict(
conflict: ConflictInfo,
resolution: ConflictResolution
): Promise<void> {
switch (resolution) {
case 'local-wins':
await pushLocalToRemote(conflict.entityId);
break;
case 'remote-wins':
await pullRemoteToLocal(conflict.entityId);
break;
case 'manual':
await showConflictResolutionUI(conflict);
break;
}
}
```
### Sync Logging
```typescript
// ✅ DO: Log all sync operations for debugging
await db.insert(syncLog).values({
id: generateId(),
entityType: 'post',
entityId: postId,
operation: 'push',
status: 'success',
timestamp: Date.now(),
});
// ✅ DO: Implement sync status visibility in UI
const pendingCount = await syncEngine.getPendingChangesCount();
statusBar.setSyncStatus({ pending: pendingCount });
```
## React & State Management
### Zustand Patterns
```typescript
// ✅ DO: Keep store slices focused
interface AppState {
// UI state
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
// Data
posts: PostData[];
media: MediaData[];
// Actions
setActiveView: (view: AppState['activeView']) => void;
setPosts: (posts: PostData[]) => void;
}
// ✅ DO: Use selectors for derived state
const draftCount = useAppStore((state) =>
state.posts.filter(p => p.status === 'draft').length
);
// ❌ DON'T: Store derived data
interface BadState {
posts: PostData[];
draftCount: number; // Bad - derived from posts
}
```
### Component Patterns
```typescript
// ✅ DO: Use composition over prop drilling
<EditorProvider postId={selectedPostId}>
<EditorToolbar />
<EditorContent />
<EditorStatusBar />
</EditorProvider>
// ✅ DO: Separate container and presentation components
// Container: handles data fetching/IPC
// Presentation: pure rendering based on props
// ❌ DON'T: Put IPC calls in deeply nested components
const DeepComponent = () => {
// Bad - hard to test and maintain
const handleClick = async () => {
await window.electronAPI.posts.save(data);
};
};
```
## File Naming Conventions
```
src/
main/
database/
schema.ts # Drizzle schema definitions
connection.ts # Database connection management
engine/
PostEngine.ts # PascalCase for classes
TaskManager.ts
ipc/
handlers.ts # camelCase for modules
renderer/
components/
Editor/
Editor.tsx # PascalCase for components
Editor.css # Matching CSS file
index.ts # Barrel exports
store/
appStore.ts # camelCase for stores
types/
electron.d.ts # Type declarations
```
## Test-Driven Development (TDD) Requirements
> **⚠️ CRITICAL: This project follows STRICT Test-Driven Development.**
>
> Writing implementation code before tests is NOT acceptable.
> Pull requests without corresponding tests will be rejected.
**All new features and bug fixes MUST have tests written BEFORE implementation.**
### The Golden Rule: Test Real Implementations
```typescript
// ✅ CORRECT: Import and test the REAL class
import { PostEngine } from '../../src/main/engine/PostEngine';
describe('PostEngine', () => {
let postEngine: PostEngine;
beforeEach(() => {
// Mock only external dependencies, NOT the class under test
postEngine = new PostEngine(mockDatabase, mockFileSystem);
});
it('should create a post with generated slug from title', async () => {
const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world');
});
});
// ❌ WRONG: Testing inline helper functions instead of real implementations
describe('PostEngine', () => {
it('should generate slug', () => {
// This tests a local function, not the actual PostEngine class!
const generateSlug = (title: string) => title.toLowerCase().replace(/ /g, '-');
expect(generateSlug('Hello World')).toBe('hello-world');
});
});
```
### TDD Workflow (Red-Green-Refactor)
```typescript
// 1. RED: Write a failing test first that uses the REAL implementation
import { PostEngine } from '../../src/main/engine/PostEngine';
describe('PostEngine.createPost', () => {
it('should create a post with generated slug from title', async () => {
const postEngine = new PostEngine(mockDb, mockFs);
const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world');
});
});
// 2. GREEN: Write minimal code in PostEngine to pass the test
// 3. REFACTOR: Improve the code while keeping tests green
```
### Test File Structure
```
tests/
├── setup.ts # Global test configuration
├── utils/
│ ├── factories.ts # Mock data factories
│ └── index.ts # Utility exports
└── engine/
├── TaskManager.test.ts # TaskManager unit tests
├── PostEngine.test.ts # PostEngine unit tests
├── MediaEngine.test.ts # MediaEngine unit tests
└── SyncEngine.test.ts # SyncEngine unit tests
```
### Test Naming Conventions
```typescript
// ✅ DO: Use descriptive test names that explain behavior
it('should generate slug from title with lowercase and hyphens', () => {});
it('should emit taskCompleted event when task finishes', () => {});
it('should return null when post not found', () => {});
// ❌ DON'T: Use vague test names
it('works', () => {});
it('test createPost', () => {});
```
### Mock Factory Patterns
```typescript
// ✅ DO: Use factory functions with overrides
import { createMockPost, createMockMedia } from '../utils/factories';
const post = createMockPost({
title: 'Custom Title',
status: 'published'
});
// ✅ DO: Create specialized factories for common scenarios
const draftPost = createMockPost({ status: 'draft' });
const publishedPost = createMockPublishedPost();
const imageMedia = createMockMedia({ mimeType: 'image/jpeg' });
const pdfMedia = createMockPdfMedia();
```
### Testing Async Code
```typescript
// ✅ DO: Use async/await in tests
it('should save post to filesystem', async () => {
const post = createMockPost();
await postEngine.createPost(post);
expect(fs.writeFile).toHaveBeenCalled();
});
// ✅ DO: Test event emissions
it('should emit postCreated event', async () => {
const handler = vi.fn();
postEngine.on('postCreated', handler);
await postEngine.createPost({ title: 'Test' });
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Test' })
);
});
// ✅ DO: Test error cases
it('should throw when file write fails', async () => {
vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC'));
await expect(postEngine.createPost({ title: 'Test' }))
.rejects.toThrow('ENOSPC');
});
```
### Mocking Dependencies
```typescript
// ✅ DO: Mock at module level for consistent behavior
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => mockDatabase),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
// ✅ DO: Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
```
### Test Coverage Requirements
- **Minimum coverage**: 80% for engine classes
- **Critical paths**: 100% coverage for data mutations
- **Edge cases**: Null handling, empty arrays, error conditions
```bash
# Run tests with coverage
npm run test:coverage
# View coverage report
open coverage/index.html
```
### When to Write Tests
| Scenario | Test Requirement |
|----------|-----------------|
| New engine method | Unit tests BEFORE implementation |
| Bug fix | Failing test that reproduces bug FIRST |
| Refactoring | Ensure existing tests pass BEFORE and AFTER |
| IPC handler | Integration test with mocked engine |
| UI component | Behavior tests for user interactions |
### Test Commands
```bash
npm run test # Run all tests once
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage report
npm run test:ui # Open Vitest UI
```
### Vitest Configuration
Tests are configured in `vitest.config.ts` with:
- Global test utilities (describe, it, expect, vi)
- Node environment for main process tests
- Coverage reports via v8
- Custom setup file for Electron mocks
## Testing Considerations
```typescript
// ✅ DO: Design for testability
// - Engine classes should accept dependencies via constructor
// - Use interfaces for external dependencies
// - Keep pure business logic separate from I/O
class PostEngine {
constructor(
private db: DatabaseConnection,
private fs: FileSystemAdapter, // Mockable
private eventEmitter: EventEmitter
) {}
}
// ✅ DO: Use factory functions for test data
function createTestPost(overrides?: Partial<PostData>): PostData {
return {
id: 'test-id',
title: 'Test Post',
status: 'draft',
...overrides,
};
}
// ✅ DO: Test pure functions in isolation
describe('generateSlug', () => {
it('should convert to lowercase', () => {
expect(generateSlug('Hello World')).toBe('hello-world');
});
it('should replace special characters', () => {
expect(generateSlug('Hello, World!')).toBe('hello-world');
});
});
```
## Common Patterns in This Codebase
### Creating a New Engine Method
```typescript
// 1. Add method to engine class
async newOperation(input: InputType): Promise<OutputType> {
// Validate input
// Perform operation
// Emit event for UI updates
this.emit('operationCompleted', result);
return result;
}
// 2. Add IPC handler
ipcMain.handle('entity:operation', async (_event, input) => {
return engine.newOperation(input);
});
// 3. Expose in preload
entity: {
operation: (input) => ipcRenderer.invoke('entity:operation', input),
}
// 4. Update store and UI
```
### Adding a New Database Table
```typescript
// 1. Define in schema.ts with proper types
export const newTable = sqliteTable('new_table', {
id: text('id').primaryKey(),
// ... columns
});
// 2. Export types
export type NewTableRow = typeof newTable.$inferSelect;
export type NewTableInsert = typeof newTable.$inferInsert;
// 3. Add CREATE TABLE to migrations in connection.ts
// 4. Create corresponding engine class
```
## Security Reminders
- Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing
- Use `contextIsolation: true` and `sandbox: false` only when necessary
- Store Dropbox auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention)

776
GEMINI.md Normal file
View File

@@ -0,0 +1,776 @@
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
## Project Overview
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
- **Electron** v28+ for cross-platform desktop
- **TypeScript** for all code (strict mode)
- **React** for the renderer UI
- **Drizzle ORM** for type-safe database access
- **@libsql/client** for SQLite (local database)
- **Zustand** for React state management
---
## ⚠️ MANDATORY: Test-First Development
**STOP!** Before writing ANY implementation code, you MUST:
1. **Write a failing test first** that describes the expected behavior
2. **Run the test** to confirm it fails (Red)
3. **Write minimal code** to make the test pass (Green)
4. **Refactor** while keeping tests green
> **No code without tests. No exceptions.**
>
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
> Mock only external dependencies (database, filesystem), never the class under test.
See the [TDD Requirements](#test-driven-development-tdd-requirements) section for detailed guidelines.
---
## ⚠️ MANDATORY: Fix All Test Failures
**You MUST investigate and fix ALL test failures before completing any task.**
- Never leave tests failing, even if they appear unrelated to your changes
- If a test failure is pre-existing, fix it as part of your current work
- Run the full test suite (`npm test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution
> **Zero failing tests. No exceptions.**
---
## ⚠️ MANDATORY: Remove Unused Code
**Never keep unused code around. Always delete it completely.**
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
- Do NOT comment out code "for later" - use version control history
- Do NOT skip tests for removed functionality - delete them
- Do NOT leave dead code paths, unused imports, or orphaned functions
- When refactoring, actively look for and remove any code that becomes unused
> **Delete unused code immediately. No exceptions.**
---
## ⚠️ MANDATORY: Build Verification After Code Changes
**You MUST run the full build after making code changes.**
- Run `npm run build` after any code modifications
- Fix ALL build errors before considering the task complete
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
- The build must complete successfully before the task is complete
> **Successful build required. No exceptions.**
---
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
- Preview HTML must reference only local/package-bundled assets
- Generated HTML must not include CDN-hosted JS/CSS libraries
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
- Avoid introducing any new `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
---
## Architecture Principles
### Separation of Concerns
1. **Engine Classes** (`src/main/engine/`): All business logic lives here
- `PostEngine`: Blog post CRUD, file I/O, markdown handling
- `MediaEngine`: Media import, metadata management
- `SyncEngine`: Remote database synchronization
- `TaskManager`: Async task queue with progress tracking
2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer
- Handlers call engine methods, never contain business logic
- Always use `ipcMain.handle` for async operations
3. **UI Components** (`src/renderer/components/`): Presentation only
- Components should be stateless where possible
- Use Zustand store for shared state
- Never call IPC directly from deeply nested components
### File Storage Pattern
- **Posts**: Markdown files with YAML frontmatter in `posts/` directory
- **Media**: Binary files with `.meta` JSON sidecar files in `media/` directory
- **Database**: Index/cache only, source of truth is filesystem
## TypeScript Best Practices
### Type Safety
```typescript
// ✅ DO: Use explicit return types for public methods
async function getPost(id: string): Promise<PostData | null> {
// ...
}
// ✅ DO: Use discriminated unions for status types
type SyncStatus = 'pending' | 'syncing' | 'synced' | 'error';
// ✅ DO: Use Drizzle's inferred types
import { posts, type Post, type NewPost } from './schema';
// ❌ DON'T: Use `any` type
function processData(data: any) { } // Bad
// ❌ DON'T: Ignore null/undefined possibilities
const post = await getPost(id);
console.log(post.title); // Bad - post might be null
```
### Async/Await Patterns
```typescript
// ✅ DO: Use try-catch with specific error handling
try {
await fileOperation();
} catch (error) {
if (error instanceof Error && error.message.includes('ENOENT')) {
// Handle file not found
}
throw error;
}
// ✅ DO: Use Promise.all for concurrent operations
const [posts, media] = await Promise.all([
postEngine.getAllPosts(),
mediaEngine.getAllMedia()
]);
// ❌ DON'T: Forget to await async operations
savePost(post); // Bad - fire and forget
```
### Error Handling
```typescript
// ✅ DO: Create typed error classes
class SyncError extends Error {
constructor(
message: string,
public readonly code: 'AUTH_FAILED' | 'NETWORK_ERROR' | 'CONFLICT'
) {
super(message);
this.name = 'SyncError';
}
}
// ✅ DO: Return result objects for operations that can fail
interface OperationResult<T> {
success: boolean;
data?: T;
error?: string;
}
```
## Electron Best Practices
### IPC Communication
```typescript
// ✅ DO: Use contextBridge for secure IPC exposure
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
posts: {
getAll: () => ipcRenderer.invoke('posts:getAll'),
create: (data: CreatePostInput) => ipcRenderer.invoke('posts:create', data),
}
});
// ✅ DO: Validate all IPC inputs in main process
ipcMain.handle('posts:create', async (_event, data: unknown) => {
const validated = validateCreatePostInput(data);
return postEngine.createPost(validated);
});
// ❌ DON'T: Use nodeIntegration: true
// ❌ DON'T: Expose Node.js APIs directly to renderer
```
### Window Management
```typescript
// ✅ DO: Check window existence before sending messages
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('event', data);
}
// ✅ DO: Clean up IPC handlers on window close
mainWindow.on('closed', () => {
ipcMain.removeHandler('posts:getAll');
});
```
### Process Separation
```typescript
// Main process: File system, database, native APIs
// Renderer process: UI only, no direct fs/db access
// ✅ DO: All file operations in main process
// src/main/engine/PostEngine.ts
await fs.writeFile(filePath, content);
// ❌ DON'T: Import 'fs' in renderer
// src/renderer/components/Editor.tsx
import fs from 'fs'; // Bad!
```
## SQLite & Drizzle ORM Best Practices
### Schema Definition
```typescript
// ✅ DO: Use proper column types and constraints
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
syncStatus: text('sync_status').$type<'pending' | 'synced'>().default('pending'),
});
// ✅ DO: Create indexes for frequently queried columns
// CREATE INDEX idx_posts_slug ON posts(slug);
// CREATE INDEX idx_posts_sync_status ON posts(sync_status);
```
### Query Patterns
```typescript
import { eq, and, desc, sql } from 'drizzle-orm';
// ✅ DO: Use Drizzle query builder
const drafts = await db
.select()
.from(posts)
.where(eq(posts.status, 'draft'))
.orderBy(desc(posts.updatedAt));
// ✅ DO: Use transactions for multi-table updates
await db.transaction(async (tx) => {
await tx.update(posts).set({ status: 'published' }).where(eq(posts.id, id));
await tx.insert(syncLog).values({ entityId: id, operation: 'update' });
});
// ❌ DON'T: Write raw SQL unless absolutely necessary
// ❌ DON'T: Use string interpolation in queries
const result = db.run(`SELECT * FROM posts WHERE id = '${id}'`); // SQL injection risk!
```
### Connection Management
```typescript
// ✅ DO: Use singleton pattern for database connection
let dbInstance: DatabaseConnection | null = null;
export function getDatabase(): DatabaseConnection {
if (!dbInstance) {
dbInstance = new DatabaseConnection();
}
return dbInstance;
}
// ✅ DO: Close connection gracefully on app quit
app.on('before-quit', async () => {
await getDatabase().close();
});
```
## Remote Sync Best Practices (Dropbox)
### Sync Strategy
```typescript
// ✅ DO: Track sync status per entity
interface SyncableEntity {
syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict';
syncedAt: number | null;
checksum: string;
}
// ✅ DO: Use checksums to detect conflicts
const localChecksum = calculateChecksum(localContent);
const remoteChecksum = await fetchRemoteChecksum(id);
if (localChecksum !== remoteChecksum) {
// Handle conflict
}
// ✅ DO: Implement retry logic with exponential backoff
async function syncWithRetry(maxAttempts = 3): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await sync();
return;
} catch (error) {
if (attempt === maxAttempts) throw error;
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
```
### Offline-First Approach
```typescript
// ✅ DO: Always write to local first
async function savePost(post: PostData): Promise<void> {
// 1. Save to local file system
await writePostFile(post);
// 2. Update local database
await updateLocalDatabase(post);
// 3. Mark for sync (don't wait for network)
await markForSync(post.id);
}
// ✅ DO: Queue sync operations
const syncQueue: SyncOperation[] = [];
function queueSync(operation: SyncOperation): void {
syncQueue.push(operation);
processSyncQueue(); // Non-blocking
}
// ✅ DO: Handle network state changes
window.addEventListener('online', () => {
syncEngine.processPendingChanges();
});
```
### Conflict Resolution
```typescript
// ✅ DO: Implement clear conflict resolution strategy
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
interface ConflictInfo {
entityId: string;
localVersion: EntityVersion;
remoteVersion: EntityVersion;
resolution?: ConflictResolution;
}
// ✅ DO: Allow user to resolve conflicts manually when needed
async function resolveConflict(
conflict: ConflictInfo,
resolution: ConflictResolution
): Promise<void> {
switch (resolution) {
case 'local-wins':
await pushLocalToRemote(conflict.entityId);
break;
case 'remote-wins':
await pullRemoteToLocal(conflict.entityId);
break;
case 'manual':
await showConflictResolutionUI(conflict);
break;
}
}
```
### Sync Logging
```typescript
// ✅ DO: Log all sync operations for debugging
await db.insert(syncLog).values({
id: generateId(),
entityType: 'post',
entityId: postId,
operation: 'push',
status: 'success',
timestamp: Date.now(),
});
// ✅ DO: Implement sync status visibility in UI
const pendingCount = await syncEngine.getPendingChangesCount();
statusBar.setSyncStatus({ pending: pendingCount });
```
## React & State Management
### Zustand Patterns
```typescript
// ✅ DO: Keep store slices focused
interface AppState {
// UI state
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
// Data
posts: PostData[];
media: MediaData[];
// Actions
setActiveView: (view: AppState['activeView']) => void;
setPosts: (posts: PostData[]) => void;
}
// ✅ DO: Use selectors for derived state
const draftCount = useAppStore((state) =>
state.posts.filter(p => p.status === 'draft').length
);
// ❌ DON'T: Store derived data
interface BadState {
posts: PostData[];
draftCount: number; // Bad - derived from posts
}
```
### Component Patterns
```typescript
// ✅ DO: Use composition over prop drilling
<EditorProvider postId={selectedPostId}>
<EditorToolbar />
<EditorContent />
<EditorStatusBar />
</EditorProvider>
// ✅ DO: Separate container and presentation components
// Container: handles data fetching/IPC
// Presentation: pure rendering based on props
// ❌ DON'T: Put IPC calls in deeply nested components
const DeepComponent = () => {
// Bad - hard to test and maintain
const handleClick = async () => {
await window.electronAPI.posts.save(data);
};
};
```
## File Naming Conventions
```
src/
main/
database/
schema.ts # Drizzle schema definitions
connection.ts # Database connection management
engine/
PostEngine.ts # PascalCase for classes
TaskManager.ts
ipc/
handlers.ts # camelCase for modules
renderer/
components/
Editor/
Editor.tsx # PascalCase for components
Editor.css # Matching CSS file
index.ts # Barrel exports
store/
appStore.ts # camelCase for stores
types/
electron.d.ts # Type declarations
```
## Test-Driven Development (TDD) Requirements
> **⚠️ CRITICAL: This project follows STRICT Test-Driven Development.**
>
> Writing implementation code before tests is NOT acceptable.
> Pull requests without corresponding tests will be rejected.
**All new features and bug fixes MUST have tests written BEFORE implementation.**
### The Golden Rule: Test Real Implementations
```typescript
// ✅ CORRECT: Import and test the REAL class
import { PostEngine } from '../../src/main/engine/PostEngine';
describe('PostEngine', () => {
let postEngine: PostEngine;
beforeEach(() => {
// Mock only external dependencies, NOT the class under test
postEngine = new PostEngine(mockDatabase, mockFileSystem);
});
it('should create a post with generated slug from title', async () => {
const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world');
});
});
// ❌ WRONG: Testing inline helper functions instead of real implementations
describe('PostEngine', () => {
it('should generate slug', () => {
// This tests a local function, not the actual PostEngine class!
const generateSlug = (title: string) => title.toLowerCase().replace(/ /g, '-');
expect(generateSlug('Hello World')).toBe('hello-world');
});
});
```
### TDD Workflow (Red-Green-Refactor)
```typescript
// 1. RED: Write a failing test first that uses the REAL implementation
import { PostEngine } from '../../src/main/engine/PostEngine';
describe('PostEngine.createPost', () => {
it('should create a post with generated slug from title', async () => {
const postEngine = new PostEngine(mockDb, mockFs);
const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world');
});
});
// 2. GREEN: Write minimal code in PostEngine to pass the test
// 3. REFACTOR: Improve the code while keeping tests green
```
### Test File Structure
```
tests/
├── setup.ts # Global test configuration
├── utils/
│ ├── factories.ts # Mock data factories
│ └── index.ts # Utility exports
└── engine/
├── TaskManager.test.ts # TaskManager unit tests
├── PostEngine.test.ts # PostEngine unit tests
├── MediaEngine.test.ts # MediaEngine unit tests
└── SyncEngine.test.ts # SyncEngine unit tests
```
### Test Naming Conventions
```typescript
// ✅ DO: Use descriptive test names that explain behavior
it('should generate slug from title with lowercase and hyphens', () => {});
it('should emit taskCompleted event when task finishes', () => {});
it('should return null when post not found', () => {});
// ❌ DON'T: Use vague test names
it('works', () => {});
it('test createPost', () => {});
```
### Mock Factory Patterns
```typescript
// ✅ DO: Use factory functions with overrides
import { createMockPost, createMockMedia } from '../utils/factories';
const post = createMockPost({
title: 'Custom Title',
status: 'published'
});
// ✅ DO: Create specialized factories for common scenarios
const draftPost = createMockPost({ status: 'draft' });
const publishedPost = createMockPublishedPost();
const imageMedia = createMockMedia({ mimeType: 'image/jpeg' });
const pdfMedia = createMockPdfMedia();
```
### Testing Async Code
```typescript
// ✅ DO: Use async/await in tests
it('should save post to filesystem', async () => {
const post = createMockPost();
await postEngine.createPost(post);
expect(fs.writeFile).toHaveBeenCalled();
});
// ✅ DO: Test event emissions
it('should emit postCreated event', async () => {
const handler = vi.fn();
postEngine.on('postCreated', handler);
await postEngine.createPost({ title: 'Test' });
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Test' })
);
});
// ✅ DO: Test error cases
it('should throw when file write fails', async () => {
vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC'));
await expect(postEngine.createPost({ title: 'Test' }))
.rejects.toThrow('ENOSPC');
});
```
### Mocking Dependencies
```typescript
// ✅ DO: Mock at module level for consistent behavior
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => mockDatabase),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
// ✅ DO: Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
```
### Test Coverage Requirements
- **Minimum coverage**: 80% for engine classes
- **Critical paths**: 100% coverage for data mutations
- **Edge cases**: Null handling, empty arrays, error conditions
```bash
# Run tests with coverage
npm run test:coverage
# View coverage report
open coverage/index.html
```
### When to Write Tests
| Scenario | Test Requirement |
|----------|-----------------|
| New engine method | Unit tests BEFORE implementation |
| Bug fix | Failing test that reproduces bug FIRST |
| Refactoring | Ensure existing tests pass BEFORE and AFTER |
| IPC handler | Integration test with mocked engine |
| UI component | Behavior tests for user interactions |
### Test Commands
```bash
npm run test # Run all tests once
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage report
npm run test:ui # Open Vitest UI
```
### Vitest Configuration
Tests are configured in `vitest.config.ts` with:
- Global test utilities (describe, it, expect, vi)
- Node environment for main process tests
- Coverage reports via v8
- Custom setup file for Electron mocks
## Testing Considerations
```typescript
// ✅ DO: Design for testability
// - Engine classes should accept dependencies via constructor
// - Use interfaces for external dependencies
// - Keep pure business logic separate from I/O
class PostEngine {
constructor(
private db: DatabaseConnection,
private fs: FileSystemAdapter, // Mockable
private eventEmitter: EventEmitter
) {}
}
// ✅ DO: Use factory functions for test data
function createTestPost(overrides?: Partial<PostData>): PostData {
return {
id: 'test-id',
title: 'Test Post',
status: 'draft',
...overrides,
};
}
// ✅ DO: Test pure functions in isolation
describe('generateSlug', () => {
it('should convert to lowercase', () => {
expect(generateSlug('Hello World')).toBe('hello-world');
});
it('should replace special characters', () => {
expect(generateSlug('Hello, World!')).toBe('hello-world');
});
});
```
## Common Patterns in This Codebase
### Creating a New Engine Method
```typescript
// 1. Add method to engine class
async newOperation(input: InputType): Promise<OutputType> {
// Validate input
// Perform operation
// Emit event for UI updates
this.emit('operationCompleted', result);
return result;
}
// 2. Add IPC handler
ipcMain.handle('entity:operation', async (_event, input) => {
return engine.newOperation(input);
});
// 3. Expose in preload
entity: {
operation: (input) => ipcRenderer.invoke('entity:operation', input),
}
// 4. Update store and UI
```
### Adding a New Database Table
```typescript
// 1. Define in schema.ts with proper types
export const newTable = sqliteTable('new_table', {
id: text('id').primaryKey(),
// ... columns
});
// 2. Export types
export type NewTableRow = typeof newTable.$inferSelect;
export type NewTableInsert = typeof newTable.$inferInsert;
// 3. Add CREATE TABLE to migrations in connection.ts
// 4. Create corresponding engine class
```
## Security Reminders
- Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing
- Use `contextIsolation: true` and `sandbox: false` only when necessary
- Store Dropbox auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention)

View File

@@ -0,0 +1,8 @@
CREATE TABLE `generated_file_hashes` (
`project_id` text NOT NULL,
`relative_path` text NOT NULL,
`content_hash` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `generated_file_hashes_project_path_idx` ON `generated_file_hashes` (`project_id`,`relative_path`);

View File

@@ -0,0 +1,813 @@
{
"version": "6",
"dialect": "sqlite",
"id": "46702982-9f8a-4c7e-8fb6-3270c3fbe120",
"prevId": "602674b9-0b1e-4d1b-aed3-125bac4d1dda",
"tables": {
"chat_conversations": {
"name": "chat_conversations",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"copilot_session_id": {
"name": "copilot_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chat_messages": {
"name": "chat_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tool_call_id": {
"name": "tool_call_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tool_calls": {
"name": "tool_calls",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"generated_file_hashes": {
"name": "generated_file_hashes",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"relative_path": {
"name": "relative_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_hash": {
"name": "content_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"generated_file_hashes_project_path_idx": {
"name": "generated_file_hashes_project_path_idx",
"columns": [
"project_id",
"relative_path"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"import_definitions": {
"name": "import_definitions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wxr_file_path": {
"name": "wxr_file_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"uploads_folder_path": {
"name": "uploads_folder_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_analysis_result": {
"name": "last_analysis_result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"media": {
"name": "media",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caption": {
"name": "caption",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sidecar_path": {
"name": "sidecar_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"post_links": {
"name": "post_links",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_post_id": {
"name": "source_post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_post_id": {
"name": "target_post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"link_text": {
"name": "link_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"post_media": {
"name": "post_media",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_id": {
"name": "post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"media_id": {
"name": "media_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"post_media_post_media_idx": {
"name": "post_media_post_media_idx",
"columns": [
"post_id",
"media_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_title": {
"name": "published_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_content": {
"name": "published_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_tags": {
"name": "published_tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_categories": {
"name": "published_categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_excerpt": {
"name": "published_excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"posts_project_slug_idx": {
"name": "posts_project_slug_idx",
"columns": [
"project_id",
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"data_path": {
"name": "data_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"projects_slug_unique": {
"name": "projects_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_project_name_idx": {
"name": "tags_project_name_idx",
"columns": [
"project_id",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1771168311850,
"tag": "0003_foamy_whiplash",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1771605253203,
"tag": "0004_overjoyed_paper_doll",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,36 @@
import { getDatabase } from './connection';
export async function getGeneratedFileHash(projectId: string, relativePath: string): Promise<string | null> {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database client not available');
}
const result = await client.execute({
sql: 'SELECT content_hash FROM generated_file_hashes WHERE project_id = ? AND relative_path = ? LIMIT 1',
args: [projectId, relativePath],
});
if (!result.rows[0] || typeof result.rows[0].content_hash !== 'string') {
return null;
}
return result.rows[0].content_hash;
}
export async function setGeneratedFileHash(projectId: string, relativePath: string, hash: string): Promise<void> {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database client not available');
}
await client.execute({
sql: `
INSERT INTO generated_file_hashes (project_id, relative_path, content_hash, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_id, relative_path)
DO UPDATE SET content_hash = excluded.content_hash, updated_at = excluded.updated_at
`,
args: [projectId, relativePath, hash, Date.now()],
});
}

View File

@@ -1,2 +1,3 @@
export * from './schema';
export * from './connection';
export * from './generatedFileHashStore';

View File

@@ -73,6 +73,16 @@ export const settings = sqliteTable('settings', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Generated file hashes - tracks html/xml output content hashes to skip unchanged writes
export const generatedFileHashes = sqliteTable('generated_file_hashes', {
projectId: text('project_id').notNull(),
relativePath: text('relative_path').notNull(),
contentHash: text('content_hash').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
projectPathIdx: uniqueIndex('generated_file_hashes_project_path_idx').on(table.projectId, table.relativePath),
}));
// Post links - tracks internal links between posts
export const postLinks = sqliteTable('post_links', {
id: text('id').primaryKey(),
@@ -150,6 +160,8 @@ export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;
export type GeneratedFileHash = typeof generatedFileHashes.$inferSelect;
export type NewGeneratedFileHash = typeof generatedFileHashes.$inferInsert;
export type PostLink = typeof postLinks.$inferSelect;
export type NewPostLink = typeof postLinks.$inferInsert;
export type PostMediaLink = typeof postMedia.$inferSelect;

View File

@@ -1,8 +1,18 @@
import * as path from 'path';
import * as fs from 'fs/promises';
import * as crypto from 'crypto';
import { getDatabase } from '../database';
import { readFile } from 'node:fs/promises';
import { getGeneratedFileHash, setGeneratedFileHash } from '../database/generatedFileHashStore';
import { getPostEngine, type PostData } from './PostEngine';
import { getMediaEngine, type MediaData } from './MediaEngine';
import { getPostMediaEngine } from './PostMediaEngine';
import {
PageRenderer,
PREVIEW_ASSETS,
PREVIEW_IMAGE_ASSETS,
buildCanonicalPostPath,
type HtmlRewriteContext,
} from './PageRenderer';
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
const MIN_MAX_POSTS_PER_PAGE = 1;
@@ -15,8 +25,13 @@ export interface BlogGenerationOptions {
dataDir: string;
baseUrl: string;
maxPostsPerPage?: number;
language?: string;
pageTitle?: string;
sections?: BlogGenerationSection[];
}
export type BlogGenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date';
export interface BlogGenerationResult {
path: string;
urlCount: number;
@@ -25,6 +40,7 @@ export interface BlogGenerationResult {
tagCount: number;
categoryCount: number;
archiveCount: number;
pagesGenerated: number;
feeds: {
rssPath: string;
atomPath: string;
@@ -145,52 +161,46 @@ function computeContentHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
async function getHashSettingValue(key: string): Promise<string | null> {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database client not available');
}
const result = await client.execute({
sql: 'SELECT value FROM settings WHERE key = ? LIMIT 1',
args: [key],
});
if (!result.rows[0] || typeof result.rows[0].value !== 'string') {
return null;
}
return result.rows[0].value;
}
async function setHashSettingValue(key: string, value: string): Promise<void> {
const client = getDatabase().getLocalClient();
if (!client) {
throw new Error('Database client not available');
}
await client.execute({
sql: 'INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
args: [key, value, new Date()],
});
}
async function writeFileIfHashChanged(filePath: string, content: string, hashKey: string): Promise<boolean> {
async function writeFileIfHashChanged(projectId: string, filePath: string, relativePath: string, content: string): Promise<boolean> {
const hash = computeContentHash(content);
const previousHash = await getHashSettingValue(hashKey);
const previousHash = await getGeneratedFileHash(projectId, relativePath);
if (previousHash === hash) {
return false;
}
await fs.writeFile(filePath, content, 'utf-8');
await setHashSettingValue(hashKey, hash);
await setGeneratedFileHash(projectId, relativePath, hash);
return true;
}
async function writeHtmlPage(projectId: string, htmlDir: string, urlPath: string, content: string): Promise<boolean> {
const normalizedPath = urlPath.replace(/^\//, '');
const filePath = normalizedPath
? path.join(htmlDir, normalizedPath, 'index.html')
: path.join(htmlDir, 'index.html');
const relativePath = normalizedPath ? `${normalizedPath}/index.html` : 'index.html';
await fs.mkdir(path.dirname(filePath), { recursive: true });
return writeFileIfHashChanged(projectId, filePath, relativePath, content);
}
export class BlogGenerationEngine {
private readonly postEngine = getPostEngine();
private readonly mediaEngine = getMediaEngine();
private readonly postMediaEngine = getPostMediaEngine();
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
onProgress(0, 'Loading posts...');
const selectedSections = new Set<BlogGenerationSection>(
options.sections && options.sections.length > 0
? options.sections
: ['core', 'single', 'category', 'tag', 'date'],
);
const includeCore = selectedSections.has('core');
const includeSingle = selectedSections.has('single');
const includeCategory = selectedSections.has('category');
const includeTag = selectedSections.has('tag');
const includeDate = selectedSections.has('date');
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
@@ -219,7 +229,7 @@ export class BlogGenerationEngine {
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const feedPosts = publishedPosts.slice(0, maxPostsPerPage);
onProgress(10, `Found ${publishedPosts.length} published posts`);
onProgress(3, `Found ${publishedPosts.length} published posts`);
const now = new Date().toISOString();
const allTags = new Set<string>();
@@ -258,7 +268,7 @@ export class BlogGenerationEngine {
const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now;
onProgress(40, 'Building sitemap XML...');
onProgress(5, 'Building sitemap XML...');
const urls: string[] = [];
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
@@ -266,7 +276,6 @@ export class BlogGenerationEngine {
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
}
onProgress(55, 'Adding archive pages...');
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
urls.push(buildSitemapUrl(`${options.baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
}
@@ -277,17 +286,15 @@ export class BlogGenerationEngine {
urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
}
onProgress(70, 'Adding category pages...');
for (const category of Array.from(allCategories).sort()) {
urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
}
onProgress(80, 'Adding tag pages...');
for (const tag of Array.from(allTags).sort()) {
urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
}
onProgress(85, 'Building RSS and Atom feeds...');
onProgress(8, 'Building RSS and Atom feeds...');
const sitemapXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
@@ -382,22 +389,93 @@ export class BlogGenerationEngine {
'',
].join('\n');
onProgress(92, 'Writing sitemap and feeds...');
const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true });
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
const rssPath = path.join(htmlDir, 'rss.xml');
const atomPath = path.join(htmlDir, 'atom.xml');
const hashKeyPrefix = `project:${options.projectId}:generation-hash`;
const [sitemapWritten, rssWritten, atomWritten] = await Promise.all([
writeFileIfHashChanged(sitemapPath, sitemapXml, `${hashKeyPrefix}:sitemap.xml`),
writeFileIfHashChanged(rssPath, rssXml, `${hashKeyPrefix}:rss.xml`),
writeFileIfHashChanged(atomPath, atomXml, `${hashKeyPrefix}:atom.xml`),
]);
const estimatedUnitsBySection = this.estimateGenerationUnitsBySection(
publishedPosts,
allCategories,
allTags,
years,
yearMonths,
yearMonthDays,
maxPostsPerPage,
);
const totalEstimatedUnits = [
includeCore ? estimatedUnitsBySection.core : 0,
includeSingle ? estimatedUnitsBySection.single : 0,
includeCategory ? estimatedUnitsBySection.category : 0,
includeTag ? estimatedUnitsBySection.tag : 0,
includeDate ? estimatedUnitsBySection.date : 0,
].reduce((sum, value) => sum + value, 0);
let completedUnits = 0;
onProgress(100, `Sitemap and feeds generated (${feedPosts.length} feed posts)`);
const reportUnitProgress = (message: string) => {
if (totalEstimatedUnits <= 0) {
return;
}
completedUnits += 1;
const progress = 10 + Math.floor((completedUnits / totalEstimatedUnits) * 85);
onProgress(Math.min(95, progress), message);
};
let sitemapWritten = false;
let rssWritten = false;
let atomWritten = false;
if (includeCore) {
onProgress(10, 'Writing sitemap and feeds...');
sitemapWritten = await writeFileIfHashChanged(options.projectId, sitemapPath, 'sitemap.xml', sitemapXml);
reportUnitProgress('Sitemap written');
rssWritten = await writeFileIfHashChanged(options.projectId, rssPath, 'rss.xml', rssXml);
reportUnitProgress('RSS feed written');
atomWritten = await writeFileIfHashChanged(options.projectId, atomPath, 'atom.xml', atomXml);
reportUnitProgress('Atom feed written');
onProgress(15, 'Copying assets...');
await this.copyAssets(htmlDir);
reportUnitProgress('Assets copied');
}
const pageTitle = options.pageTitle || options.projectName;
const language = options.language || 'en';
const pageContext = { page_title: pageTitle, language };
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine);
const rewriteContext = this.buildHtmlRewriteContext(publishedPosts);
let pagesGenerated = 0;
if (includeCore) {
onProgress(20, 'Generating root pages...');
pagesGenerated += await this.generateRootPages(options.projectId, publishedPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
pagesGenerated += await this.generatePageRoutes(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress);
}
if (includeSingle) {
onProgress(35, 'Generating single post pages...');
pagesGenerated += await this.generateSinglePostPages(options.projectId, publishedPosts, rewriteContext, htmlDir, pageContext, pageRenderer, reportUnitProgress);
}
if (includeCategory) {
onProgress(50, 'Generating category pages...');
pagesGenerated += await this.generateCategoryPages(options.projectId, publishedPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
}
if (includeTag) {
onProgress(65, 'Generating tag pages...');
pagesGenerated += await this.generateTagPages(options.projectId, publishedPosts, allTags, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
}
if (includeDate) {
onProgress(80, 'Generating date archive pages...');
pagesGenerated += await this.generateDateArchivePages(options.projectId, publishedPosts, years, yearMonths, yearMonthDays, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, reportUnitProgress);
}
onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`);
return {
path: sitemapPath,
@@ -407,6 +485,7 @@ export class BlogGenerationEngine {
tagCount: allTags.size,
categoryCount: allCategories.size,
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
pagesGenerated,
feeds: {
rssPath,
atomPath,
@@ -418,6 +497,395 @@ export class BlogGenerationEngine {
},
};
}
private async generatePageRoutes(
projectId: string,
posts: PostData[],
rewriteContext: HtmlRewriteContext,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
let count = 0;
const pagePosts = posts.filter((post) => (post.categories || []).includes('page'));
for (const post of pagePosts) {
const html = await pageRenderer.renderSinglePost(post, rewriteContext, pageContext);
await writeHtmlPage(projectId, htmlDir, post.slug, html);
count++;
onPageGenerated(`Generated /${post.slug}`);
}
return count;
}
private buildHtmlRewriteContext(publishedPosts: PostData[]): HtmlRewriteContext {
const canonicalPostPathBySlug = new Map<string, string>();
for (const post of publishedPosts) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
}
const canonicalMediaPathBySourcePath = new Map<string, string>();
return {
canonicalPostPathBySlug,
canonicalMediaPathBySourcePath,
};
}
private async copyAssets(htmlDir: string): Promise<void> {
const assetsDir = path.join(htmlDir, 'assets');
const imagesDir = path.join(htmlDir, 'images');
await fs.mkdir(assetsDir, { recursive: true });
await fs.mkdir(imagesDir, { recursive: true });
for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) {
const sourcePath = require.resolve(definition.modulePath);
const destPath = path.join(assetsDir, filename);
const content = await readFile(sourcePath);
await fs.writeFile(destPath, content);
}
for (const [filename, definition] of Object.entries(PREVIEW_IMAGE_ASSETS)) {
const sourcePath = require.resolve(definition.modulePath);
const destPath = path.join(imagesDir, filename);
const content = await readFile(sourcePath);
await fs.writeFile(destPath, content);
}
}
private async generateRootPages(
projectId: string,
posts: PostData[],
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage));
let count = 0;
for (let page = 1; page <= totalPages; page++) {
const offset = (page - 1) * maxPostsPerPage;
const pagePosts = posts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
archiveGrouping: false,
routeKind: 'date',
archiveContext: { kind: 'root' },
basePathname: '/',
pagination: { page, maxPostsPerPage, totalPosts: posts.length },
...pageContext,
});
if (html) {
const urlPath = page === 1 ? '' : `page/${page}`;
await writeHtmlPage(projectId, htmlDir, urlPath, html);
count++;
onPageGenerated(urlPath ? `Generated /${urlPath}` : 'Generated /');
}
}
return count;
}
private async generateSinglePostPages(
projectId: string,
posts: PostData[],
rewriteContext: HtmlRewriteContext,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
let count = 0;
for (const post of posts) {
const createdAt = resolvePostCreatedAt(post);
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
const html = await pageRenderer.renderSinglePost(post, rewriteContext, pageContext);
const urlPath = `${year}/${month}/${day}/${post.slug}`;
await writeHtmlPage(projectId, htmlDir, urlPath, html);
count++;
onPageGenerated(`Generated /${urlPath}`);
}
return count;
}
private async generateCategoryPages(
projectId: string,
posts: PostData[],
allCategories: Set<string>,
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
let count = 0;
for (const category of Array.from(allCategories).sort()) {
const categoryPosts = posts.filter((post) => (post.categories || []).includes(category));
if (categoryPosts.length === 0) continue;
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage));
const encodedCategory = encodeURIComponent(category);
const basePathname = `/category/${encodedCategory}`;
for (let page = 1; page <= totalPages; page++) {
const offset = (page - 1) * maxPostsPerPage;
const pagePosts = categoryPosts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
archiveGrouping: true,
routeKind: 'non-date',
archiveContext: { kind: 'category', name: category },
basePathname,
pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length },
...pageContext,
});
if (html) {
const urlPath = page === 1
? `category/${encodedCategory}`
: `category/${encodedCategory}/page/${page}`;
await writeHtmlPage(projectId, htmlDir, urlPath, html);
count++;
onPageGenerated(`Generated /${urlPath}`);
}
}
}
return count;
}
private async generateTagPages(
projectId: string,
posts: PostData[],
allTags: Set<string>,
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
let count = 0;
for (const tag of Array.from(allTags).sort()) {
const tagPosts = posts.filter((post) => (post.tags || []).includes(tag));
if (tagPosts.length === 0) continue;
const totalPages = Math.max(1, Math.ceil(tagPosts.length / maxPostsPerPage));
const encodedTag = encodeURIComponent(tag);
const basePathname = `/tag/${encodedTag}`;
for (let page = 1; page <= totalPages; page++) {
const offset = (page - 1) * maxPostsPerPage;
const pagePosts = tagPosts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
archiveGrouping: true,
routeKind: 'non-date',
archiveContext: { kind: 'tag', name: tag },
basePathname,
pagination: { page, maxPostsPerPage, totalPosts: tagPosts.length },
...pageContext,
});
if (html) {
const urlPath = page === 1
? `tag/${encodedTag}`
: `tag/${encodedTag}/page/${page}`;
await writeHtmlPage(projectId, htmlDir, urlPath, html);
count++;
onPageGenerated(`Generated /${urlPath}`);
}
}
}
return count;
}
private async generateDateArchivePages(
projectId: string,
posts: PostData[],
yearsMap: Map<number, Date>,
yearMonthsMap: Map<string, Date>,
yearMonthDaysMap: Map<string, Date>,
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
let count = 0;
for (const [year] of Array.from(yearsMap.entries()).sort((a, b) => b[0] - a[0])) {
const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year);
count += await this.generatePaginatedListPages(
projectId, yearPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated,
`${year}`, `/${year}`, { kind: 'year', year }, 'date',
);
}
for (const [ym] of Array.from(yearMonthsMap.entries()).sort().reverse()) {
const [yearStr, monthStr] = ym.split('/');
const year = Number(yearStr);
const month = Number(monthStr);
const monthPosts = posts.filter((post) => {
const d = resolvePostCreatedAt(post);
return d.getFullYear() === year && (d.getMonth() + 1) === month;
});
count += await this.generatePaginatedListPages(
projectId, monthPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated,
ym, `/${ym}`, { kind: 'month', year, month }, 'date',
);
}
for (const [ymd] of Array.from(yearMonthDaysMap.entries()).sort().reverse()) {
const [yearStr, monthStr, dayStr] = ymd.split('/');
const year = Number(yearStr);
const month = Number(monthStr);
const day = Number(dayStr);
const dayPosts = posts.filter((post) => {
const d = resolvePostCreatedAt(post);
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
});
count += await this.generatePaginatedListPages(
projectId, dayPosts, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, onPageGenerated,
ymd, `/${ymd}`, { kind: 'day', year, month, day }, 'date',
);
}
return count;
}
private async generatePaginatedListPages(
projectId: string,
posts: PostData[],
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
urlPrefix: string,
basePathname: string,
archiveContext: { kind: 'root' | 'year' | 'month' | 'day' | 'tag' | 'category'; name?: string; year?: number; month?: number; day?: number },
routeKind: 'date' | 'non-date',
): Promise<number> {
if (posts.length === 0) return 0;
const totalPages = Math.max(1, Math.ceil(posts.length / maxPostsPerPage));
let count = 0;
for (let page = 1; page <= totalPages; page++) {
const offset = (page - 1) * maxPostsPerPage;
const pagePosts = posts.slice(offset, offset + maxPostsPerPage);
if (pagePosts.length === 0) break;
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
archiveGrouping: true,
routeKind,
archiveContext,
basePathname,
pagination: { page, maxPostsPerPage, totalPosts: posts.length },
...pageContext,
});
if (html) {
const urlPath = page === 1
? urlPrefix
: `${urlPrefix}/page/${page}`;
await writeHtmlPage(projectId, htmlDir, urlPath, html);
count++;
onPageGenerated(`Generated /${urlPath}`);
}
}
return count;
}
private estimateGenerationUnitsBySection(
posts: PostData[],
allCategories: Set<string>,
allTags: Set<string>,
yearsMap: Map<number, Date>,
yearMonthsMap: Map<string, Date>,
yearMonthDaysMap: Map<string, Date>,
maxPostsPerPage: number,
): Record<BlogGenerationSection, number> {
const rootPages = this.countPaginatedPages(posts.length, maxPostsPerPage);
const pageRoutes = posts.filter((post) => (post.categories || []).includes('page')).length;
const categoryPages = Array.from(allCategories).reduce((sum, category) => {
const count = posts.filter((post) => (post.categories || []).includes(category)).length;
return sum + this.countPaginatedPages(count, maxPostsPerPage);
}, 0);
const tagPages = Array.from(allTags).reduce((sum, tag) => {
const count = posts.filter((post) => (post.tags || []).includes(tag)).length;
return sum + this.countPaginatedPages(count, maxPostsPerPage);
}, 0);
let datePages = 0;
for (const [year] of yearsMap) {
const yearPosts = posts.filter((post) => resolvePostCreatedAt(post).getFullYear() === year);
datePages += this.countPaginatedPages(yearPosts.length, maxPostsPerPage);
}
for (const [ym] of yearMonthsMap) {
const [yearStr, monthStr] = ym.split('/');
const year = Number(yearStr);
const month = Number(monthStr);
const monthPosts = posts.filter((post) => {
const d = resolvePostCreatedAt(post);
return d.getFullYear() === year && (d.getMonth() + 1) === month;
});
datePages += this.countPaginatedPages(monthPosts.length, maxPostsPerPage);
}
for (const [ymd] of yearMonthDaysMap) {
const [yearStr, monthStr, dayStr] = ymd.split('/');
const year = Number(yearStr);
const month = Number(monthStr);
const day = Number(dayStr);
const dayPosts = posts.filter((post) => {
const d = resolvePostCreatedAt(post);
return d.getFullYear() === year && (d.getMonth() + 1) === month && d.getDate() === day;
});
datePages += this.countPaginatedPages(dayPosts.length, maxPostsPerPage);
}
return {
core: 4 + rootPages + pageRoutes,
single: posts.length,
category: categoryPages,
tag: tagPages,
date: datePages,
};
}
private countPaginatedPages(totalPosts: number, maxPostsPerPage: number): number {
if (totalPosts <= 0) {
return 0;
}
return Math.max(1, Math.ceil(totalPosts / maxPostsPerPage));
}
}
let blogGenerationEngine: BlogGenerationEngine | null = null;

View File

@@ -0,0 +1,812 @@
import path from 'node:path';
import { marked } from 'marked';
import { Liquid } from 'liquidjs';
import type { MediaData } from './MediaEngine';
import type { PostData } from './PostEngine';
export interface HtmlRewriteContext {
canonicalPostPathBySlug: Map<string, string>;
canonicalMediaPathBySourcePath: Map<string, string>;
}
export interface TemplatePostEntry {
id: string;
title: string;
content: string;
}
export interface DayBlockContext {
date_label: string;
show_date_marker: boolean;
show_separator: boolean;
posts: TemplatePostEntry[];
}
export interface PaginationContext {
page: number;
maxPostsPerPage: number;
totalPosts: number;
}
export type ArchiveRouteKind = 'date' | 'non-date';
export type DateArchiveContext = {
kind: 'root' | 'year' | 'month' | 'day' | 'tag' | 'category';
name?: string;
year?: number;
month?: number;
day?: number;
};
export interface PostListTemplateContext {
page_title: string;
language: string;
is_date_archive: boolean;
show_archive_range_heading: boolean;
archive_context: {
kind: 'root' | 'year' | 'month' | 'day' | 'tag' | 'category';
name: string | null;
year: number | null;
month: number | null;
day: number | null;
} | null;
min_date: { day: number; month: number; year: number } | null;
max_date: { day: number; month: number; year: number } | null;
is_list_page: boolean;
is_first_page: boolean;
is_last_page: boolean;
has_prev_page: boolean;
has_next_page: boolean;
prev_page_href: string;
next_page_href: string;
canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>;
day_blocks: DayBlockContext[];
}
export interface SinglePostTemplateContext {
page_title: string;
language: string;
post: TemplatePostEntry;
canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>;
}
export interface NotFoundTemplateContext {
page_title: string;
language: string;
}
export interface RoutePagination {
pathname: string;
page: number;
}
export interface MediaEngineContract {
getAllMedia: () => Promise<MediaData[]>;
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
}
export interface PostMediaEngineContract {
getLinkedMediaDataForPost: (postId: string) => Promise<Array<{ media: MediaData }>>;
setProjectContext: (projectId: string) => void;
}
export interface PostEngineContract {
getPost: (id: string) => Promise<PostData | null>;
}
export const PREVIEW_ASSETS = {
'pico.min.css': {
modulePath: '@picocss/pico/css/pico.min.css',
contentType: 'text/css; charset=utf-8',
},
'lightbox.min.css': {
modulePath: 'lightbox2/dist/css/lightbox.min.css',
contentType: 'text/css; charset=utf-8',
},
'lightbox.min.js': {
modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
contentType: 'application/javascript; charset=utf-8',
},
} as const;
export const PREVIEW_IMAGE_ASSETS = {
'prev.png': {
modulePath: 'lightbox2/dist/images/prev.png',
contentType: 'image/png',
},
'next.png': {
modulePath: 'lightbox2/dist/images/next.png',
contentType: 'image/png',
},
'close.png': {
modulePath: 'lightbox2/dist/images/close.png',
contentType: 'image/png',
},
'loading.gif': {
modulePath: 'lightbox2/dist/images/loading.gif',
contentType: 'image/gif',
},
} as const;
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
const MIN_MAX_POSTS_PER_PAGE = 1;
const MAX_MAX_POSTS_PER_PAGE = 500;
export 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;
}
export function resolvePageTitle(metadata: { description?: string; name?: string } | null, fallbackProjectName?: string, fallbackProjectDescription?: string): string {
const candidate = metadata?.description?.trim();
if (candidate) {
return candidate;
}
const metadataName = metadata?.name?.trim();
if (metadataName) {
return metadataName;
}
const descriptionFallback = fallbackProjectDescription?.trim();
if (descriptionFallback) {
return descriptionFallback;
}
const fallback = fallbackProjectName?.trim();
if (fallback) {
return fallback;
}
return 'Blog Preview';
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export 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;
}
export function parseIntegerParam(value: string | undefined): number | null {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : null;
}
export function normalizeMacroName(name: string): string {
if (name === 'photo_album') {
return 'photo_archive';
}
return name;
}
export function buildCanonicalMediaPath(media: MediaData): string {
const year = media.createdAt.getFullYear();
const month = String(media.createdAt.getMonth() + 1).padStart(2, '0');
return `/media/${year}/${month}/${media.filename}`;
}
export function isRenderableImage(media: MediaData): boolean {
if (media.mimeType?.toLowerCase().startsWith('image/')) {
return true;
}
const extension = path.extname(media.filename).toLowerCase();
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.avif'].includes(extension);
}
export function buildPhotoArchiveBuckets(
mediaItems: MediaData[],
params: Record<string, string>,
): Array<{ year: number; month: number; media: MediaData[] }> {
const yearParam = parseIntegerParam(params.year);
const monthParam = parseIntegerParam(params.month);
const filteredByDate = mediaItems.filter((media) => {
const year = media.createdAt.getFullYear();
const month = media.createdAt.getMonth() + 1;
if (yearParam !== null && year !== yearParam) {
return false;
}
if (monthParam !== null && month !== monthParam) {
return false;
}
return true;
});
const buckets = new Map<string, { year: number; month: number; media: MediaData[] }>();
for (const media of filteredByDate) {
const year = media.createdAt.getFullYear();
const month = media.createdAt.getMonth() + 1;
const key = `${year}-${String(month).padStart(2, '0')}`;
const existing = buckets.get(key);
if (existing) {
existing.media.push(media);
continue;
}
buckets.set(key, { year, month, media: [media] });
}
let orderedBuckets = Array.from(buckets.values())
.sort((a, b) => (b.year * 12 + b.month) - (a.year * 12 + a.month));
if (yearParam === null) {
orderedBuckets = orderedBuckets.slice(0, 10);
}
for (const bucket of orderedBuckets) {
bucket.media.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
return orderedBuckets;
}
export function renderGalleryMacro(
params: Record<string, string>,
postId: string,
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
): string {
const requestedColumns = parseIntegerParam(params.columns);
const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3;
const caption = params.caption ? `<figcaption class="gallery-caption">${escapeHtml(params.caption)}</figcaption>` : '';
const linkedImages = mediaItems
.filter((media) => {
if (!isRenderableImage(media)) {
return false;
}
const linkedByPostMedia = linkedMediaIds?.has(media.id) ?? false;
const linkedBySidecar = Array.isArray(media.linkedPostIds) && media.linkedPostIds.includes(postId);
return linkedByPostMedia || linkedBySidecar;
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const groupName = `gallery-${escapeHtml(postId || 'post')}`;
const galleryItems = linkedImages.map((media) => {
const mediaPath = escapeHtml(buildCanonicalMediaPath(media));
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename);
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename);
return `<a class="gallery-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
}).join('');
const content = galleryItems || '<div class="gallery-empty">No linked images found.</div>';
return `<div class="macro-gallery gallery-cols-${columns}" data-post-id="${escapeHtml(postId)}" data-columns="${columns}" data-lightbox="true"><div class="gallery-container gallery-lightbox">${content}</div>${caption}</div>`;
}
export function renderPhotoArchiveMacro(params: Record<string, string>, mediaItems: MediaData[]): string {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const yearParam = parseIntegerParam(params.year);
const monthParam = parseIntegerParam(params.month);
const rootClasses = ['macro-photo-archive'];
if (yearParam === null) {
rootClasses.push('photo-archive-recent-months');
} else if (monthParam !== null) {
rootClasses.push('photo-archive-single-month');
} else {
rootClasses.push('photo-archive-full-year');
}
const dataAttrs: string[] = [];
if (yearParam === null) {
dataAttrs.push('data-recent="10"');
} else {
dataAttrs.push(`data-year="${escapeHtml(String(yearParam))}"`);
if (monthParam !== null) {
dataAttrs.push(`data-month="${escapeHtml(String(monthParam))}"`);
}
}
const renderableMedia = mediaItems.filter((media) => isRenderableImage(media));
const buckets = buildPhotoArchiveBuckets(renderableMedia, params);
if (buckets.length === 0) {
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container"><div class="photo-archive-empty">No photos found for this archive.</div></div></div>`;
}
const monthsHtml = buckets.map((bucket) => {
const monthName = monthNames[bucket.month - 1] || String(bucket.month);
const label = `${monthName} ${bucket.year}`;
const groupName = `photo-archive-${bucket.year}-${String(bucket.month).padStart(2, '0')}`;
const itemsHtml = bucket.media.map((media) => {
const mediaPath = escapeHtml(buildCanonicalMediaPath(media));
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename);
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename);
return `<a class="photo-archive-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
}).join('');
return `<div class="photo-archive-month-wrapper"><div class="photo-archive-month"><div class="photo-archive-month-label"><span>${escapeHtml(label)}</span></div><div class="photo-archive-gallery">${itemsHtml}</div></div></div>`;
}).join('');
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container">${monthsHtml}</div></div>`;
}
export 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);
}
export function splitPathSuffix(value: string): { pathPart: string; suffix: string } {
const match = value.match(/^([^?#]*)([?#].*)?$/);
return {
pathPart: match?.[1] ?? value,
suffix: match?.[2] ?? '',
};
}
export 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;
}
export 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}`;
}
export 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}`;
});
}
export function renderMacro(
name: string,
params: Record<string, string>,
postId: string,
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
): string {
const normalizedName = normalizeMacroName(name);
if (normalizedName === '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 (normalizedName === '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 (normalizedName === 'gallery') {
return renderGalleryMacro(params, postId, mediaItems, linkedMediaIds);
}
if (normalizedName === 'photo_archive') {
return renderPhotoArchiveMacro(params, mediaItems);
}
return '';
}
export 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}`;
}
export function formatArchiveDate(date: Date): string {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = String(date.getFullYear());
return `${day}.${month}.${year}`;
}
export function getArchiveDateKey(date: Date): string {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function toDateParts(date: Date): { day: number; month: number; year: number } {
return {
day: date.getDate(),
month: date.getMonth() + 1,
year: date.getFullYear(),
};
}
export function buildPaginationHref(basePathname: string, page: number): string {
const base = basePathname === '/' ? '' : basePathname;
if (page <= 1) {
return basePathname === '/' ? '/' : `${basePathname}/`;
}
return `${base}/page/${page}/`;
}
export function parseRoutePagination(pathname: string): RoutePagination | null {
const pageMatch = pathname.match(/^(.*)\/page\/(\d+)$/);
if (!pageMatch) {
return { pathname, page: 1 };
}
const page = Number(pageMatch[2]);
if (!Number.isInteger(page) || page < 1) {
return null;
}
const basePathname = pageMatch[1] || '/';
return {
pathname: basePathname,
page,
};
}
export function mapToRecord(map: Map<string, string>): Record<string, string> {
return Object.fromEntries(map.entries());
}
export function recordToMap(record: unknown): Map<string, string> {
if (!record || typeof record !== 'object') {
return new Map<string, string>();
}
return new Map<string, string>(
Object.entries(record as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
);
}
export class PageRenderer {
private readonly mediaEngine: MediaEngineContract;
private readonly postMediaEngine: PostMediaEngineContract;
private readonly liquid: Liquid;
constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract) {
this.mediaEngine = mediaEngine;
this.postMediaEngine = postMediaEngine;
const templateRoots = [
path.resolve(__dirname, 'templates'),
path.resolve(process.cwd(), 'dist', 'main', 'engine', 'templates'),
path.resolve(process.cwd(), 'src', 'main', 'engine', 'templates'),
];
this.liquid = new Liquid({
root: templateRoots,
extname: '.liquid',
cache: true,
});
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown) => {
const content = typeof value === 'string' ? value : '';
const postId = typeof postIdArg === 'string' ? postIdArg : '';
const rewriteContext: HtmlRewriteContext = {
canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
};
const needsMediaLookup = /\[\[(gallery|photo_archive|photo_album)\b/i.test(content);
const mediaItems = needsMediaLookup
? await this.mediaEngine.getAllMedia().catch(() => [] as MediaData[])
: [];
const linkedMediaIds = needsMediaLookup && postId
? await this.postMediaEngine.getLinkedMediaDataForPost(postId)
.then((links) => new Set<string>(links.map((link) => link.media?.id).filter((id): id is string => typeof id === 'string' && id.length > 0)))
.catch(() => null)
: null;
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
const params = parseMacroParams(rawParams);
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds);
});
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
return rewriteRenderedHtmlUrls(markdownHtml, rewriteContext);
});
}
buildListTemplateContext(
posts: PostData[],
rewriteContext: HtmlRewriteContext,
options: {
archiveGrouping: boolean;
routeKind: ArchiveRouteKind;
archiveContext?: DateArchiveContext;
basePathname: string;
page_title: string;
language: string;
pagination?: PaginationContext;
},
): PostListTemplateContext {
const dayBlocks: DayBlockContext[] = [];
if (!options.archiveGrouping) {
dayBlocks.push({
date_label: '',
show_date_marker: false,
show_separator: false,
posts: posts.map((post) => ({
id: post.id,
title: post.title,
content: post.content,
})),
});
} else {
let currentBlock: { key: string; block: DayBlockContext } | null = null;
for (const post of posts) {
const key = getArchiveDateKey(post.createdAt);
if (!currentBlock || currentBlock.key !== key) {
currentBlock = {
key,
block: {
date_label: formatArchiveDate(post.createdAt),
show_date_marker: true,
show_separator: false,
posts: [],
},
};
dayBlocks.push(currentBlock.block);
}
currentBlock.block.posts.push({
id: post.id,
title: post.title,
content: post.content,
});
}
for (let index = 0; index < dayBlocks.length - 1; index += 1) {
dayBlocks[index].show_separator = true;
}
}
const pagination = options.pagination;
const isListPage = Boolean(pagination && pagination.totalPosts > pagination.maxPostsPerPage);
const isFirstPage = pagination ? pagination.page <= 1 : true;
const isLastPage = pagination
? (pagination.page * pagination.maxPostsPerPage) >= pagination.totalPosts
: true;
const hasPrevPage = Boolean(pagination && pagination.page > 1);
const hasNextPage = Boolean(pagination && (pagination.page * pagination.maxPostsPerPage) < pagination.totalPosts);
const prevPageHref = hasPrevPage
? buildPaginationHref(options.basePathname, (pagination as PaginationContext).page - 1)
: '';
const nextPageHref = hasNextPage
? buildPaginationHref(options.basePathname, (pagination as PaginationContext).page + 1)
: '';
let minDateParts: { day: number; month: number; year: number } | null = null;
let maxDateParts: { day: number; month: number; year: number } | null = null;
const hasRangeHeading = Boolean(
!isFirstPage
&& posts.length > 0
&& (
options.routeKind === 'date'
|| options.archiveContext?.kind === 'tag'
|| options.archiveContext?.kind === 'category'
),
);
if (hasRangeHeading) {
let minDate = posts[0].createdAt;
let maxDate = posts[0].createdAt;
for (const post of posts) {
if (post.createdAt.getTime() < minDate.getTime()) {
minDate = post.createdAt;
}
if (post.createdAt.getTime() > maxDate.getTime()) {
maxDate = post.createdAt;
}
}
minDateParts = toDateParts(minDate);
maxDateParts = toDateParts(maxDate);
}
return {
page_title: options.page_title,
language: options.language,
is_date_archive: options.routeKind === 'date',
show_archive_range_heading: hasRangeHeading,
archive_context: options.routeKind === 'date'
? {
kind: options.archiveContext?.kind ?? 'root',
name: options.archiveContext?.name ?? null,
year: options.archiveContext?.year ?? null,
month: options.archiveContext?.month ?? null,
day: options.archiveContext?.day ?? null,
}
: options.archiveContext
? {
kind: options.archiveContext.kind,
name: options.archiveContext.name ?? null,
year: options.archiveContext.year ?? null,
month: options.archiveContext.month ?? null,
day: options.archiveContext.day ?? null,
}
: null,
min_date: minDateParts,
max_date: maxDateParts,
is_list_page: isListPage,
is_first_page: isFirstPage,
is_last_page: isLastPage,
has_prev_page: hasPrevPage,
has_next_page: hasNextPage,
prev_page_href: prevPageHref,
next_page_href: nextPageHref,
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
day_blocks: dayBlocks,
};
}
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract): Promise<PostData> {
if (post.status === 'published' && !post.content) {
const fullPost = await postEngine.getPost(post.id);
return fullPost ?? post;
}
return post;
}
async renderPostList(
posts: PostData[],
rewriteContext: HtmlRewriteContext,
options: {
archiveGrouping: boolean;
routeKind: ArchiveRouteKind;
archiveContext?: DateArchiveContext;
basePathname: string;
page_title: string;
language: string;
pagination?: PaginationContext;
},
postEngine?: PostEngineContract,
): Promise<string> {
if (posts.length === 0) {
return '';
}
const renderablePosts = postEngine
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine)))
: posts;
const templateContext = this.buildListTemplateContext(
renderablePosts,
rewriteContext,
options,
);
return this.liquid.renderFile('post-list', templateContext);
}
async renderSinglePost(
post: PostData,
rewriteContext: HtmlRewriteContext,
pageContext: { page_title: string; language: string },
postEngine?: PostEngineContract,
): Promise<string> {
const renderablePost = postEngine
? await this.resolveRenderablePost(post, postEngine)
: post;
const context: SinglePostTemplateContext = {
...pageContext,
post: {
id: renderablePost.id,
title: renderablePost.title,
content: renderablePost.content,
},
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
};
return this.liquid.renderFile('single-post', context);
}
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
return this.liquid.renderFile('not-found', context);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,15 @@ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cance
export interface TaskProgress {
taskId: string;
name: string;
status: TaskStatus;
progress: number; // 0-100
message: string;
startTime: Date;
endTime?: Date;
error?: string;
groupId?: string;
groupName?: string;
}
export interface Task<T = unknown> {
@@ -17,6 +20,8 @@ export interface Task<T = unknown> {
name: string;
execute: (onProgress: (progress: number, message: string) => void) => Promise<T>;
cancel?: () => void;
groupId?: string;
groupName?: string;
}
export class TaskManager extends EventEmitter {
@@ -44,10 +49,13 @@ export class TaskManager extends EventEmitter {
async runTask<T>(task: Task<T>): Promise<T> {
const progress: TaskProgress = {
taskId: task.id,
name: task.name,
status: 'pending',
progress: 0,
message: 'Waiting to start...',
startTime: new Date(),
groupId: task.groupId,
groupName: task.groupName,
};
this.tasks.set(task.id, progress);

View File

@@ -2,8 +2,16 @@ import { dialog } from 'electron';
import { getPostEngine } from '../engine/PostEngine';
import { getProjectEngine } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { getMediaEngine } from '../engine/MediaEngine';
import { getPostMediaEngine } from '../engine/PostMediaEngine';
import { taskManager } from '../engine/TaskManager';
import { getBlogGenerationEngine, resolvePublicBaseUrl } from '../engine/BlogGenerationEngine';
import {
getBlogGenerationEngine,
resolvePublicBaseUrl,
type BlogGenerationResult,
type BlogGenerationSection,
} from '../engine/BlogGenerationEngine';
import { resolvePageTitle } from '../engine/PageRenderer';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
@@ -12,6 +20,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
const projectEngine = getProjectEngine();
const postEngine = getPostEngine();
const metaEngine = getMetaEngine();
const mediaEngine = getMediaEngine();
const postMediaEngine = getPostMediaEngine();
const blogGenerationEngine = getBlogGenerationEngine();
const project = await projectEngine.getActiveProject();
@@ -22,6 +32,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
postEngine.setProjectContext(project.id, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
postMediaEngine.setProjectContext(project.id);
if (!metaEngine.isInitialized()) {
await metaEngine.syncOnStartup();
@@ -33,27 +45,90 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
await dialog.showMessageBox({
type: 'warning',
title: 'Public URL Required',
message: 'Sitemap generation requires a public URL.',
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
message: 'Site rendering requires a public URL.',
detail: 'Set Project → Public URL in Settings before rendering the site.',
});
throw new Error('Project public URL is not configured');
}
const taskId = `sitemap-generate-${Date.now()}`;
return taskManager.runTask({
id: taskId,
name: 'Generate Sitemap',
execute: async (onProgress) => {
return blogGenerationEngine.generate({
const taskTimestamp = Date.now();
const taskGroupId = `site-render-${taskTimestamp}`;
const taskGroupName = 'Render Site';
const language = metadata?.mainLanguage?.trim() || 'en';
const pageTitle = resolvePageTitle(metadata, project.name, project.description ?? undefined);
const baseOptions = {
projectId: project.id,
projectName: metadata?.name?.trim() || project.name,
projectDescription: metadata?.description,
dataDir,
baseUrl,
maxPostsPerPage: metadata?.maxPostsPerPage,
language,
pageTitle,
};
const runSectionTask = async (
section: BlogGenerationSection,
taskName: string,
taskIdPrefix: string,
): Promise<BlogGenerationResult> => {
return taskManager.runTask({
id: `${taskIdPrefix}-${taskTimestamp}`,
name: taskName,
groupId: taskGroupId,
groupName: taskGroupName,
execute: async (onProgress) => {
return blogGenerationEngine.generate({
...baseOptions,
sections: [section],
}, (progress, message) => onProgress(progress, message || ''));
},
});
};
const mergeResults = (results: BlogGenerationResult[]): BlogGenerationResult => {
const first = results[0];
return {
path: first.path,
urlCount: Math.max(...results.map((result) => result.urlCount)),
postCount: Math.max(...results.map((result) => result.postCount)),
feedPostCount: Math.max(...results.map((result) => result.feedPostCount)),
tagCount: Math.max(...results.map((result) => result.tagCount)),
categoryCount: Math.max(...results.map((result) => result.categoryCount)),
archiveCount: Math.max(...results.map((result) => result.archiveCount)),
pagesGenerated: results.reduce((sum, result) => sum + result.pagesGenerated, 0),
feeds: {
rssPath: first.feeds.rssPath,
atomPath: first.feeds.atomPath,
},
changed: {
sitemap: results.some((result) => result.changed.sitemap),
rss: results.some((result) => result.changed.rss),
atom: results.some((result) => result.changed.atom),
},
};
};
const coreResult = await taskManager.runTask({
id: `site-render-core-${taskTimestamp}`,
name: 'Render Site Core',
groupId: taskGroupId,
groupName: taskGroupName,
execute: async (onProgress) => {
return blogGenerationEngine.generate({
...baseOptions,
sections: ['core'],
}, (progress, message) => onProgress(progress, message || ''));
},
});
const [singleResult, categoryResult, tagResult, dateResult] = await Promise.all([
runSectionTask('single', 'Render Single Posts', 'site-render-single'),
runSectionTask('category', 'Render Category Archives', 'site-render-category'),
runSectionTask('tag', 'Render Tag Archives', 'site-render-tag'),
runSectionTask('date', 'Render Date Archives', 'site-render-date'),
]);
return mergeResults([coreResult, singleResult, categoryResult, tagResult, dateResult]);
});
}

View File

@@ -122,12 +122,15 @@ export interface MediaSearchResult {
export interface TaskProgress {
taskId: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
startTime: string;
endTime?: string;
error?: string;
groupId?: string;
groupName?: string;
}
export interface SyncConfig {
@@ -598,6 +601,7 @@ export interface ElectronAPI {
tagCount: number;
categoryCount: number;
archiveCount: number;
pagesGenerated: number;
}>;
};
chat: {

View File

@@ -134,6 +134,13 @@ const App: React.FC = () => {
);
// Task events
unsubscribers.push(
window.electronAPI?.on('task:created', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('task:started', (task: unknown) => {
const t = task as TaskProgress;

View File

@@ -86,6 +86,35 @@
gap: 4px;
}
.task-group-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-group-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
border: none;
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
border-radius: 4px;
padding: 6px 8px;
cursor: pointer;
text-align: left;
}
.task-group-chevron {
color: var(--vscode-descriptionForeground);
}
.task-group-title {
font-size: 12px;
font-weight: 600;
}
.task-item {
display: flex;
align-items: center;
@@ -131,20 +160,49 @@
min-width: 0;
}
.task-message {
.task-child-row {
margin-left: 16px;
}
.task-name {
font-size: 12px;
color: var(--vscode-editor-foreground);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-message {
font-size: 11px;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-progress-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.task-progress-bar {
flex: 1;
height: 3px;
background-color: var(--vscode-input-background);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.task-progress-value {
min-width: 32px;
font-size: 11px;
color: var(--vscode-descriptionForeground);
text-align: right;
}
.task-progress-fill {
height: 100%;
background-color: var(--vscode-focusBorder);

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from '../../store';
import type { TaskProgress } from '../../../main/shared/electronApi';
import './Panel.css';
function getPostRelativePath(createdAt: string, slug: string): string | null {
@@ -33,6 +34,72 @@ function toRelativePath(absolutePath: string, projectPath: string): string {
return normalizedAbsolute;
}
interface GroupedTaskEntry {
kind: 'group';
groupId: string;
groupName: string;
tasks: TaskProgress[];
}
interface SingleTaskEntry {
kind: 'single';
task: TaskProgress;
}
type TaskEntry = GroupedTaskEntry | SingleTaskEntry;
function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] {
const groupMap = new Map<string, { groupName: string; tasks: TaskProgress[]; firstIndex: number }>();
const singles: Array<{ task: TaskProgress; index: number }> = [];
tasks.forEach((task, index) => {
if (!task.groupId) {
singles.push({ task, index });
return;
}
const existing = groupMap.get(task.groupId);
if (existing) {
existing.tasks.push(task);
return;
}
groupMap.set(task.groupId, {
groupName: task.groupName || task.groupId,
tasks: [task],
firstIndex: index,
});
});
const entries: Array<{ entry: TaskEntry; index: number }> = [];
for (const single of singles) {
entries.push({
index: single.index,
entry: {
kind: 'single',
task: single.task,
},
});
}
for (const [groupId, group] of groupMap.entries()) {
entries.push({
index: group.firstIndex,
entry: {
kind: 'group',
groupId,
groupName: group.groupName,
tasks: group.tasks,
},
});
}
return entries
.sort((a, b) => a.index - b.index)
.map((entry) => entry.entry);
}
export const Panel: React.FC = () => {
const {
panelVisible,
@@ -48,6 +115,7 @@ export const Panel: React.FC = () => {
setSelectedPost,
setActiveView,
} = useAppStore();
const [collapsedTaskGroups, setCollapsedTaskGroups] = useState<Set<string>>(new Set());
const [gitLogLoading, setGitLogLoading] = useState(false);
const [gitLogError, setGitLogError] = useState<string | null>(null);
const [postLinksLoading, setPostLinksLoading] = useState(false);
@@ -69,6 +137,7 @@ export const Panel: React.FC = () => {
const requestIdRef = useRef(0);
const recentTasks = tasks.slice(-10).reverse();
const recentTaskEntries = useMemo(() => buildTaskEntries(recentTasks), [recentTasks]);
const activeEditorTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
const canActivatePostLinks = activeEditorTab?.type === 'post';
const canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media';
@@ -230,6 +299,52 @@ export const Panel: React.FC = () => {
setActiveView('posts');
};
const toggleTaskGroup = (groupId: string) => {
setCollapsedTaskGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const renderTaskRow = (task: TaskProgress, isChild = false) => (
<div key={task.taskId} className={`task-item status-${task.status} ${isChild ? 'task-child-row' : ''}`.trim()}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-name">{task.name || task.taskId}</div>
<div className="task-message">{task.message}</div>
{(task.status === 'running' || task.status === 'pending') && (
<div className="task-progress-row">
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
<span className="task-progress-value">{Math.round(task.progress)}%</span>
</div>
)}
</div>
{task.status === 'running' && (
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
>
Cancel
</button>
)}
</div>
);
return (
<div className="panel">
<div className="panel-header">
@@ -292,35 +407,28 @@ export const Panel: React.FC = () => {
<div className="panel-empty">No recent tasks</div>
) : (
<div className="task-list">
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item status-${task.status}`}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
</div>
{task.status === 'running' && (
{recentTaskEntries.map((entry) => {
if (entry.kind === 'single') {
return renderTaskRow(entry.task);
}
const expanded = !collapsedTaskGroups.has(entry.groupId);
return (
<div key={entry.groupId} className="task-group-row">
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
type="button"
className="task-group-toggle"
onClick={() => toggleTaskGroup(entry.groupId)}
aria-expanded={expanded}
aria-label={`${entry.groupName} (${entry.tasks.length})`}
>
Cancel
<span className="task-group-chevron">{expanded ? '▾' : '▸'}</span>
<span className="task-group-title">{entry.groupName} ({entry.tasks.length})</span>
</button>
)}
{expanded && entry.tasks.map((task) => renderTaskRow(task, true))}
</div>
))}
);
})}
</div>
)
)}

View File

@@ -108,6 +108,41 @@
background-color: var(--vscode-list-hoverBackground);
}
.task-group {
display: block;
}
.task-group-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
background: transparent;
color: var(--vscode-foreground);
text-align: left;
cursor: pointer;
}
.task-group-toggle:hover {
background-color: var(--vscode-list-hoverBackground);
}
.task-group-chevron {
color: var(--vscode-descriptionForeground);
width: 10px;
}
.task-group-title {
font-size: 12px;
font-weight: 600;
}
.task-group-child {
padding-left: 28px;
}
.task-item-info {
display: flex;
align-items: flex-start;

View File

@@ -1,10 +1,78 @@
import React, { useState, useRef, useEffect } from 'react';
import { useAppStore } from '../../store';
import type { TaskProgress } from '../../../main/shared/electronApi';
import './TaskPopup.css';
interface GroupedTaskEntry {
kind: 'group';
groupId: string;
groupName: string;
tasks: TaskProgress[];
}
interface SingleTaskEntry {
kind: 'single';
task: TaskProgress;
}
type TaskEntry = GroupedTaskEntry | SingleTaskEntry;
function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] {
const groupMap = new Map<string, { groupName: string; tasks: TaskProgress[]; firstIndex: number }>();
const singles: Array<{ task: TaskProgress; index: number }> = [];
tasks.forEach((task, index) => {
if (!task.groupId) {
singles.push({ task, index });
return;
}
const existing = groupMap.get(task.groupId);
if (existing) {
existing.tasks.push(task);
return;
}
groupMap.set(task.groupId, {
groupName: task.groupName || task.groupId,
tasks: [task],
firstIndex: index,
});
});
const groupedEntries: Array<{ entry: TaskEntry; index: number }> = [];
for (const single of singles) {
groupedEntries.push({
index: single.index,
entry: {
kind: 'single',
task: single.task,
},
});
}
for (const [groupId, group] of groupMap.entries()) {
groupedEntries.push({
index: group.firstIndex,
entry: {
kind: 'group',
groupId,
groupName: group.groupName,
tasks: group.tasks,
},
});
}
return groupedEntries
.sort((a, b) => a.index - b.index)
.map((item) => item.entry);
}
export const TaskPopup: React.FC = () => {
const { tasks } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const popupRef = useRef<HTMLDivElement>(null);
const runningTasks = tasks.filter(t => t.status === 'running');
@@ -20,6 +88,10 @@ export const TaskPopup: React.FC = () => {
const hasActiveTasks = runningTasks.length > 0 || pendingTasks.length > 0;
const runningEntries = buildTaskEntries(runningTasks);
const pendingEntries = buildTaskEntries(pendingTasks);
const recentEntries = buildTaskEntries(recentTasks);
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -67,6 +139,74 @@ export const TaskPopup: React.FC = () => {
}
};
const toggleGroup = (groupId: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const renderTaskItem = (task: TaskProgress, className: string = '') => (
<div key={task.taskId} className={`task-item ${task.status} ${className}`.trim()}>
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
{task.error && (
<div className="task-item-error">{task.error}</div>
)}
</div>
</div>
{(task.status === 'running' || task.status === 'pending') && (
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
)}
{(task.status === 'completed' || task.status === 'failed') && task.endTime && (
<span className="task-time">{formatTime(task.endTime)}</span>
)}
</div>
);
const renderEntries = (entries: TaskEntry[]) => entries.map((entry) => {
if (entry.kind === 'single') {
return renderTaskItem(entry.task);
}
const isExpanded = !collapsedGroups.has(entry.groupId);
return (
<div key={entry.groupId} className="task-group">
<button
className="task-group-toggle"
onClick={() => toggleGroup(entry.groupId)}
aria-expanded={isExpanded}
aria-label={`${entry.groupName} (${entry.tasks.length})`}
>
<span className="task-group-chevron">{isExpanded ? '▾' : '▸'}</span>
<span className="task-group-title">{entry.groupName} ({entry.tasks.length})</span>
</button>
{isExpanded && entry.tasks.map((task) => renderTaskItem(task, 'task-group-child'))}
</div>
);
});
if (!hasActiveTasks && recentTasks.length === 0) {
return null;
}
@@ -107,74 +247,21 @@ export const TaskPopup: React.FC = () => {
{runningTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Running</div>
{runningTasks.map(task => (
<div key={task.taskId} className="task-item running">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
{renderEntries(runningEntries)}
</div>
)}
{pendingTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Pending</div>
{pendingTasks.map(task => (
<div key={task.taskId} className="task-item pending">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
{renderEntries(pendingEntries)}
</div>
)}
{recentTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Recent</div>
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item ${task.status}`}>
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
{task.error && (
<div className="task-item-error">{task.error}</div>
)}
</div>
</div>
{task.endTime && (
<span className="task-time">{formatTime(task.endTime)}</span>
)}
</div>
))}
{renderEntries(recentEntries)}
</div>
)}

View File

@@ -358,7 +358,7 @@ export const useAppStore = create<AppState>()(
return { tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)) };
}
// Add new task if it doesn't exist yet
return { tasks: [...state.tasks, { taskId, status: 'running', progress: 0, message: '', startTime: new Date().toISOString(), ...task } as TaskProgress] };
return { tasks: [...state.tasks, { taskId, name: '', status: 'running', progress: 0, message: '', startTime: new Date().toISOString(), ...task } as TaskProgress] };
}),
// Loading Actions

View File

@@ -0,0 +1,514 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, rm, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import type { PostData } from '../../src/main/engine/PostEngine';
const generatedFileHashes = new Map<string, string>();
const getGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string) => {
const key = `${projectId}:${relativePath}`;
return generatedFileHashes.get(key) ?? null;
});
const setGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string, hash: string) => {
const key = `${projectId}:${relativePath}`;
generatedFileHashes.set(key, hash);
});
const executeDbSql = vi.fn(async (input: { sql: string; args?: unknown[] }) => {
const sqlText = input.sql.replace(/\s+/g, ' ').trim();
const args = input.args ?? [];
if (sqlText.startsWith('CREATE TABLE IF NOT EXISTS generated_file_hashes')) {
return { rows: [] };
}
if (sqlText.startsWith('SELECT content_hash FROM generated_file_hashes')) {
const key = `${String(args[0] ?? '')}:${String(args[1] ?? '')}`;
const hash = generatedFileHashes.get(key);
return { rows: hash ? [{ content_hash: hash }] : [] };
}
if (sqlText.startsWith('INSERT INTO generated_file_hashes')) {
const key = `${String(args[0] ?? '')}:${String(args[1] ?? '')}`;
generatedFileHashes.set(key, String(args[2] ?? ''));
return { rows: [] };
}
return { rows: [] };
});
vi.mock('../../src/main/database/generatedFileHashStore', () => ({
getGeneratedFileHash: getGeneratedFileHashMock,
setGeneratedFileHash: setGeneratedFileHashMock,
}));
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocalClient: vi.fn(() => ({
execute: executeDbSql,
})),
})),
}));
vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/engine/PostEngine')>();
const mockPostEngine = {
getPostsFiltered: vi.fn(async () => []),
getPublishedVersion: vi.fn(async () => null),
getPost: vi.fn(async () => null),
setProjectContext: vi.fn(),
};
return {
...actual,
getPostEngine: vi.fn(() => mockPostEngine),
__mockPostEngine: mockPostEngine,
};
});
vi.mock('../../src/main/engine/MediaEngine', () => {
const mockMediaEngine = {
getAllMedia: vi.fn(async () => []),
setProjectContext: vi.fn(),
};
return {
getMediaEngine: vi.fn(() => mockMediaEngine),
__mockMediaEngine: mockMediaEngine,
};
});
vi.mock('../../src/main/engine/PostMediaEngine', () => {
const mockPostMediaEngine = {
getLinkedMediaDataForPost: vi.fn(async () => []),
setProjectContext: vi.fn(),
};
return {
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
__mockPostMediaEngine: mockPostMediaEngine,
};
});
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-01-15T10:00:00.000Z');
const updatedAt = overrides.updatedAt ?? createdAt;
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'default',
title: overrides.title ?? 'Test Post',
slug: overrides.slug ?? 'test-post',
excerpt: overrides.excerpt,
content: overrides.content ?? '# Test\n\nBody text',
status: overrides.status ?? 'published',
author: overrides.author,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt ?? createdAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
};
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await stat(filePath);
return true;
} catch {
return false;
}
}
async function listFiles(dir: string, prefix = ''): Promise<string[]> {
const files: string[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...await listFiles(path.join(dir, entry.name), relative));
} else {
files.push(relative);
}
}
} catch {
// dir doesn't exist
}
return files.sort();
}
describe('BlogGenerationEngine', () => {
let tempDir: string;
let mockPostEngine: any;
beforeEach(async () => {
vi.clearAllMocks();
generatedFileHashes.clear();
tempDir = await mkdtemp(path.join(tmpdir(), 'bds-gen-'));
const { __mockPostEngine } = await import('../../src/main/engine/PostEngine') as any;
mockPostEngine = __mockPostEngine;
});
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
});
function setupPosts(posts: PostData[]): void {
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
return posts.filter((p) => p.status === (filter.status ?? p.status));
});
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
mockPostEngine.getPost.mockImplementation(async (id: string) => {
return posts.find((p) => p.id === id) ?? null;
});
}
async function generate(posts: PostData[], options?: Partial<{ maxPostsPerPage: number; language: string; pageTitle: string }>) {
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const onProgress = vi.fn();
return engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
maxPostsPerPage: options?.maxPostsPerPage,
language: options?.language,
pageTitle: options?.pageTitle,
}, onProgress);
}
it('copies all required asset files to html/assets/ and html/images/', async () => {
const result = await generate([]);
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'pico.min.css'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.css'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.js'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'images', 'prev.png'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'images', 'next.png'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'images', 'close.png'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'images', 'loading.gif'))).toBe(true);
const picoContent = await readFile(path.join(tempDir, 'html', 'assets', 'pico.min.css'), 'utf-8');
expect(picoContent.length).toBeGreaterThan(0);
});
it('generates root index.html for published posts', async () => {
const posts = [
makePost({ id: '1', slug: 'first', title: 'First Post' }),
makePost({ id: '2', slug: 'second', title: 'Second Post' }),
];
const result = await generate(posts);
expect(result.pagesGenerated).toBeGreaterThan(0);
const indexPath = path.join(tempDir, 'html', 'index.html');
expect(await fileExists(indexPath)).toBe(true);
const html = await readFile(indexPath, 'utf-8');
expect(html).toContain('data-template="post-list"');
expect(html).toContain('/assets/pico.min.css');
expect(html).toContain('/assets/lightbox.min.css');
});
it('generates single post pages at /{year}/{month}/{day}/{slug}/index.html', async () => {
const posts = [
makePost({ id: '1', slug: 'hello-world', createdAt: new Date('2025-03-15T10:00:00Z') }),
];
await generate(posts);
const postPath = path.join(tempDir, 'html', '2025', '03', '15', 'hello-world', 'index.html');
expect(await fileExists(postPath)).toBe(true);
const html = await readFile(postPath, 'utf-8');
expect(html).toContain('data-template="single-post"');
});
it('generates category pages with correct archive context', async () => {
const posts = [
makePost({ id: '1', slug: 'news-1', title: 'News 1', categories: ['news'] }),
makePost({ id: '2', slug: 'tech-1', title: 'Tech 1', categories: ['tech'] }),
];
await generate(posts);
const newsPath = path.join(tempDir, 'html', 'category', 'news', 'index.html');
const techPath = path.join(tempDir, 'html', 'category', 'tech', 'index.html');
expect(await fileExists(newsPath)).toBe(true);
expect(await fileExists(techPath)).toBe(true);
const newsHtml = await readFile(newsPath, 'utf-8');
expect(newsHtml).toContain('news');
expect(newsHtml).toContain('data-template="post-list"');
});
it('generates tag pages with correct archive context', async () => {
const posts = [
makePost({ id: '1', slug: 'tagged-1', title: 'Tagged 1', tags: ['javascript'] }),
makePost({ id: '2', slug: 'tagged-2', title: 'Tagged 2', tags: ['typescript'] }),
];
await generate(posts);
const jsPath = path.join(tempDir, 'html', 'tag', 'javascript', 'index.html');
const tsPath = path.join(tempDir, 'html', 'tag', 'typescript', 'index.html');
expect(await fileExists(jsPath)).toBe(true);
expect(await fileExists(tsPath)).toBe(true);
const jsHtml = await readFile(jsPath, 'utf-8');
expect(jsHtml).toContain('javascript');
expect(jsHtml).toContain('data-template="post-list"');
});
it('generates pagination pages for categories with many posts', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 5; i++) {
posts.push(makePost({
id: `post-${i}`,
slug: `post-${i}`,
title: `Post ${i}`,
categories: ['big-category'],
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
}));
}
await generate(posts, { maxPostsPerPage: 2 });
expect(await fileExists(path.join(tempDir, 'html', 'category', 'big-category', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'category', 'big-category', 'page', '2', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'category', 'big-category', 'page', '3', 'index.html'))).toBe(true);
});
it('generates pagination pages for tags with many posts', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 4; i++) {
posts.push(makePost({
id: `post-${i}`,
slug: `post-${i}`,
title: `Post ${i}`,
tags: ['popular'],
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
}));
}
await generate(posts, { maxPostsPerPage: 2 });
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'popular', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'popular', 'page', '2', 'index.html'))).toBe(true);
});
it('generates root pagination pages', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 4; i++) {
posts.push(makePost({
id: `post-${i}`,
slug: `post-${i}`,
title: `Post ${i}`,
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
}));
}
await generate(posts, { maxPostsPerPage: 2 });
expect(await fileExists(path.join(tempDir, 'html', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'page', '2', 'index.html'))).toBe(true);
});
it('generates year, month, and day archive pages', async () => {
const posts = [
makePost({ id: '1', slug: 'jan-post', createdAt: new Date('2025-01-15T10:00:00Z') }),
makePost({ id: '2', slug: 'feb-post', createdAt: new Date('2025-02-20T10:00:00Z') }),
];
await generate(posts);
// Year archive
expect(await fileExists(path.join(tempDir, 'html', '2025', 'index.html'))).toBe(true);
// Month archives
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', 'index.html'))).toBe(true);
// Day archives
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', '20', 'index.html'))).toBe(true);
});
it('excludes draft-only posts from generated pages', async () => {
const posts = [
makePost({ id: '1', slug: 'published', title: 'Published', status: 'published' }),
makePost({ id: '2', slug: 'draft-only', title: 'Draft Only', status: 'draft' }),
];
const result = await generate(posts);
expect(result.postCount).toBe(1);
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'published', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'draft-only', 'index.html'))).toBe(false);
});
it('includes draft posts that have a published version', async () => {
const publishedPost = makePost({ id: '1', slug: 'with-published', title: 'Published Version', status: 'published' });
const draftWithPublished = makePost({ id: '2', slug: 'draft-has-pub', title: 'Draft Has Published', status: 'draft' });
const publishedVersion = makePost({ id: '2', slug: 'draft-has-pub', title: 'Published Version of Draft', status: 'published', createdAt: new Date('2025-02-10T10:00:00Z') });
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
if (filter.status === 'published') return [publishedPost];
if (filter.status === 'draft') return [draftWithPublished];
return [];
});
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
if (id === '2') return publishedVersion;
return null;
});
mockPostEngine.getPost.mockImplementation(async (id: string) => {
if (id === '1') return publishedPost;
if (id === '2') return publishedVersion;
return null;
});
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const result = await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
}, vi.fn());
expect(result.postCount).toBe(2);
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', '10', 'draft-has-pub', 'index.html'))).toBe(true);
});
it('returns correct pagesGenerated count', async () => {
const posts = [
makePost({ id: '1', slug: 'post-a', categories: ['news'], tags: ['js'], createdAt: new Date('2025-01-15T10:00:00Z') }),
];
const result = await generate(posts);
// Should have: root(1) + single(1) + category/news(1) + tag/js(1) + year(1) + month(1) + day(1) = 7
expect(result.pagesGenerated).toBe(7);
});
it('generates HTML that references local assets not CDN', async () => {
const posts = [makePost({ id: '1', slug: 'test' })];
await generate(posts);
const indexHtml = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
expect(indexHtml).toContain('href="/assets/pico.min.css"');
expect(indexHtml).toContain('href="/assets/lightbox.min.css"');
expect(indexHtml).toContain('src="/assets/lightbox.min.js"');
expect(indexHtml).not.toContain('cdn.jsdelivr.net');
expect(indexHtml).not.toContain('cdnjs.cloudflare.com');
});
it('handles categories with special characters via URL encoding', async () => {
const posts = [
makePost({ id: '1', slug: 'special', categories: ['my category'] }),
];
await generate(posts);
expect(await fileExists(path.join(tempDir, 'html', 'category', 'my%20category', 'index.html'))).toBe(true);
});
it('generates static page routes at /{slug}/index.html for posts in category page', async () => {
const posts = [
makePost({ id: 'page-1', slug: 'about', title: 'About', categories: ['page'] }),
makePost({ id: 'post-1', slug: 'hello-world', title: 'Hello World', categories: ['blog'] }),
];
await generate(posts);
expect(await fileExists(path.join(tempDir, 'html', 'about', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'hello-world', 'index.html'))).toBe(false);
});
it('generates canonical post routes only and does not generate aliases', async () => {
const posts = [
makePost({ id: '1', slug: 'alias-test', createdAt: new Date('2025-03-15T10:00:00Z') }),
];
await generate(posts);
expect(await fileExists(path.join(tempDir, 'html', '2025', '03', '15', 'alias-test', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'posts', 'alias-test', 'index.html'))).toBe(false);
expect(await fileExists(path.join(tempDir, 'html', 'posts', '2025', '03', 'alias-test', 'index.html'))).toBe(false);
expect(await fileExists(path.join(tempDir, 'html', 'post', 'alias-test', 'index.html'))).toBe(false);
expect(await fileExists(path.join(tempDir, 'html', 'post', '2025', '03', 'alias-test', 'index.html'))).toBe(false);
});
it('does not overwrite unchanged html files on subsequent generation runs', async () => {
const posts = [
makePost({ id: '1', slug: 'stable-post', createdAt: new Date('2025-03-15T10:00:00Z') }),
];
await generate(posts);
const canonicalPath = path.join(tempDir, 'html', '2025', '03', '15', 'stable-post', 'index.html');
const beforeStat = await stat(canonicalPath);
await new Promise((resolve) => setTimeout(resolve, 20));
await generate(posts);
const afterStat = await stat(canonicalPath);
expect(afterStat.mtimeMs).toBe(beforeStat.mtimeMs);
});
it('delegates hash reads/writes to generated file hash store module', async () => {
const posts = [makePost({ id: '1', slug: 'delegation-check' })];
await generate(posts);
expect(getGeneratedFileHashMock).toHaveBeenCalled();
expect(setGeneratedFileHashMock).toHaveBeenCalled();
});
it('does not execute CREATE TABLE statements during generation runtime', async () => {
const posts = [makePost({ id: '1', slug: 'runtime-ddl-test' })];
await generate(posts);
const createTableCalls = executeDbSql.mock.calls.filter(([input]) => {
const sql = typeof input?.sql === 'string' ? input.sql : '';
return sql.toUpperCase().includes('CREATE TABLE');
});
expect(createTableCalls).toHaveLength(0);
});
it('does not create html/media folder during generation', async () => {
const posts = [makePost({ id: '1', slug: 'test' })];
await generate(posts);
expect(await fileExists(path.join(tempDir, 'html', 'media'))).toBe(false);
});
it('generates zero pages when there are no published posts', async () => {
const result = await generate([]);
expect(result.pagesGenerated).toBe(0);
expect(result.postCount).toBe(0);
});
it('generates pagination links in list pages', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 4; i++) {
posts.push(makePost({
id: `p-${i}`,
slug: `p-${i}`,
title: `Post ${i}`,
tags: ['paginated'],
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
}));
}
await generate(posts, { maxPostsPerPage: 2 });
const page1 = await readFile(path.join(tempDir, 'html', 'tag', 'paginated', 'index.html'), 'utf-8');
expect(page1).toContain('/tag/paginated/page/2/');
const page2 = await readFile(path.join(tempDir, 'html', 'tag', 'paginated', 'page', '2', 'index.html'), 'utf-8');
expect(page2).toContain('/tag/paginated/');
});
});

View File

@@ -175,7 +175,7 @@ const mockTaskManager = {
off: vi.fn(),
};
const mockSettingsStore = new Map<string, string>();
const mockGeneratedFileHashStore = new Map<string, string>();
const mockDatabase = {
getLocal: vi.fn(() => ({
@@ -189,19 +189,23 @@ const mockDatabase = {
})),
getLocalClient: vi.fn(() => ({
execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
if (sql.startsWith('SELECT value FROM settings WHERE key = ?')) {
const key = String(args?.[0] ?? '');
if (sql.includes('CREATE TABLE IF NOT EXISTS generated_file_hashes')) {
return { rows: [] };
}
if (sql.startsWith('SELECT content_hash FROM generated_file_hashes')) {
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
return {
rows: mockSettingsStore.has(key)
? [{ value: mockSettingsStore.get(key) as string }]
rows: mockGeneratedFileHashStore.has(key)
? [{ content_hash: mockGeneratedFileHashStore.get(key) as string }]
: [],
};
}
if (sql.startsWith('INSERT INTO settings')) {
const key = String(args?.[0] ?? '');
const value = String(args?.[1] ?? '');
mockSettingsStore.set(key, value);
if (sql.includes('INSERT INTO generated_file_hashes')) {
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
const value = String(args?.[2] ?? '');
mockGeneratedFileHashStore.set(key, value);
return { rowsAffected: 1 };
}
@@ -258,6 +262,10 @@ vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => mockDatabase),
}));
vi.mock('../../src/main/database/connection', () => ({
getDatabase: vi.fn(() => mockDatabase),
}));
vi.mock('../../src/main/engine/stemmer', () => ({
isoToStemmerLanguage: vi.fn((iso: string) => iso === 'en' ? 'english' : 'german'),
}));
@@ -294,7 +302,7 @@ describe('IPC Handlers', () => {
// Clear all mocks
vi.clearAllMocks();
registeredHandlers.clear();
mockSettingsStore.clear();
mockGeneratedFileHashStore.clear();
resetMockCounters();
// Import and register handlers fresh for each test
@@ -1571,6 +1579,62 @@ describe('IPC Handlers', () => {
// ============ Blog Handlers ============
describe('Blog Handlers', () => {
describe('blog:generateSitemap', () => {
it('should create separate background tasks for single, category, tag, and date rendering', async () => {
const mockProject = createMockProject({
id: 'test-project',
dataPath: '/mock/data',
});
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
mockMetaEngine.getProjectMetadata.mockResolvedValue({
name: 'Test Project',
publicUrl: 'https://blog.example.com',
});
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
if (filter.status === 'published') {
return [
{
id: 'post-1',
projectId: 'test-project',
title: 'Test Post',
slug: 'test-post',
excerpt: '',
content: '# Test',
status: 'published',
createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-20T15:00:00Z'),
publishedAt: new Date('2024-01-15T10:00:00Z'),
tags: ['tag1'],
categories: ['category1'],
},
];
}
if (filter.status === 'draft') {
return [];
}
return [];
});
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
const { writeFile, mkdir } = await import('fs/promises');
vi.mocked(mkdir).mockResolvedValue(undefined);
vi.mocked(writeFile).mockResolvedValue(undefined);
mockTaskManager.runTask.mockImplementation(async (task: any) => {
return task.execute(vi.fn());
});
await invokeHandler('blog:generateSitemap');
const names = mockTaskManager.runTask.mock.calls.map((call: any[]) => call[0]?.name);
expect(names).toContain('Render Site Core');
expect(names).toContain('Render Single Posts');
expect(names).toContain('Render Category Archives');
expect(names).toContain('Render Tag Archives');
expect(names).toContain('Render Date Archives');
});
it('should call taskManager.runTask with sitemap generation task', async () => {
const mockProject = createMockProject({
id: 'test-project',
@@ -1644,11 +1708,11 @@ describe('IPC Handlers', () => {
const result = await invokeHandler('blog:generateSitemap');
// Verify taskManager.runTask was called
// Verify taskManager.runTask was called for core task orchestration
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^sitemap-generate-\d+$/),
name: 'Generate Sitemap',
id: expect.stringMatching(/^site-render-core-\d+$/),
name: 'Render Site Core',
execute: expect.any(Function),
})
);
@@ -1838,7 +1902,11 @@ describe('IPC Handlers', () => {
vi.mocked(writeFile).mockClear();
await invokeHandler('blog:generateSitemap');
expect(writeFile).not.toHaveBeenCalled();
// Assets are always copied, but sitemap/feeds/pages should not be rewritten
const xmlWrites = vi.mocked(writeFile).mock.calls.filter(
([filePath]) => typeof filePath === 'string' && (filePath.endsWith('.xml') || filePath.endsWith('index.html')),
);
expect(xmlWrites).toHaveLength(0);
});
it('should throw error when no active project', async () => {

View File

@@ -234,4 +234,39 @@ describe('Panel', () => {
expect(screen.getByRole('tab', { name: 'Output' })).toHaveAttribute('aria-selected', 'true');
});
it('renders grouped tasks as expandable parent rows with child task names in tasks tab', async () => {
useAppStore.setState({
tasks: [
{
taskId: 'site-render-core-1',
name: 'Render Site Core',
status: 'running',
progress: 42,
message: 'Generating root pages...',
startTime: '2026-02-20T10:00:00.000Z',
groupId: 'site-render-1',
groupName: 'Render Site',
},
{
taskId: 'site-render-tag-1',
name: 'Render Tag Archives',
status: 'pending',
progress: 0,
message: 'Waiting to start...',
startTime: '2026-02-20T10:00:01.000Z',
groupId: 'site-render-1',
groupName: 'Render Site',
},
] as any,
});
render(<Panel />);
const parent = screen.getByRole('button', { name: 'Render Site (2)' });
expect(parent).toBeInTheDocument();
expect(await screen.findByText('Render Site Core')).toBeInTheDocument();
expect(screen.getByText('Render Tag Archives')).toBeInTheDocument();
expect(screen.getByText('42%')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { TaskPopup } from '../../../src/renderer/components/TaskPopup/TaskPopup';
import { useAppStore } from '../../../src/renderer/store';
import type { TaskProgress } from '../../../src/main/shared/electronApi';
function makeTask(overrides: Partial<TaskProgress> = {}): TaskProgress {
return {
taskId: overrides.taskId ?? 'task-1',
status: overrides.status ?? 'running',
progress: overrides.progress ?? 10,
message: overrides.message ?? 'Running task',
startTime: overrides.startTime ?? '2026-02-20T10:00:00.000Z',
endTime: overrides.endTime,
error: overrides.error,
groupId: overrides.groupId,
groupName: overrides.groupName,
};
}
describe('TaskPopup grouped tasks', () => {
beforeEach(() => {
vi.clearAllMocks();
useAppStore.setState({
tasks: [],
});
});
it('shows grouped render tasks and expands to list child tasks', () => {
useAppStore.setState({
tasks: [
makeTask({
taskId: 'site-render-core-1',
status: 'running',
message: 'Generating root pages',
groupId: 'site-render-1',
groupName: 'Render Site',
}),
makeTask({
taskId: 'site-render-date-1',
status: 'running',
message: 'Generating date archive pages',
groupId: 'site-render-1',
groupName: 'Render Site',
}),
],
});
render(<TaskPopup />);
fireEvent.click(screen.getByRole('button', { name: /running/i }));
const groupToggle = screen.getByRole('button', { name: /Render Site \(2\)/i });
expect(groupToggle).toBeInTheDocument();
expect(screen.getByText('Generating root pages')).toBeInTheDocument();
expect(screen.getByText('Generating date archive pages')).toBeInTheDocument();
fireEvent.click(groupToggle);
expect(screen.queryByText('Generating root pages')).not.toBeInTheDocument();
expect(screen.queryByText('Generating date archive pages')).not.toBeInTheDocument();
});
it('continues to render ungrouped tasks directly', () => {
useAppStore.setState({
tasks: [
makeTask({ taskId: 'ungrouped-1', status: 'running', message: 'Standalone task' }),
],
});
render(<TaskPopup />);
fireEvent.click(screen.getByRole('button', { name: /running/i }));
expect(screen.getByText('Standalone task')).toBeInTheDocument();
});
});