feat: first cut at the full renderer
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
"Bash(npm run build:*)",
|
"Bash(npm run build:*)",
|
||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(node ./node_modules/typescript/bin/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
776
CLAUDE.md
Normal 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
776
GEMINI.md
Normal 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)
|
||||||
8
drizzle/0004_overjoyed_paper_doll.sql
Normal file
8
drizzle/0004_overjoyed_paper_doll.sql
Normal 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`);
|
||||||
813
drizzle/meta/0004_snapshot.json
Normal file
813
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1771168311850,
|
"when": 1771168311850,
|
||||||
"tag": "0003_foamy_whiplash",
|
"tag": "0003_foamy_whiplash",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771605253203,
|
||||||
|
"tag": "0004_overjoyed_paper_doll",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
36
src/main/database/generatedFileHashStore.ts
Normal file
36
src/main/database/generatedFileHashStore.ts
Normal 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()],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './schema';
|
export * from './schema';
|
||||||
export * from './connection';
|
export * from './connection';
|
||||||
|
export * from './generatedFileHashStore';
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export const settings = sqliteTable('settings', {
|
|||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
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
|
// Post links - tracks internal links between posts
|
||||||
export const postLinks = sqliteTable('post_links', {
|
export const postLinks = sqliteTable('post_links', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -150,6 +160,8 @@ export type Media = typeof media.$inferSelect;
|
|||||||
export type NewMedia = typeof media.$inferInsert;
|
export type NewMedia = typeof media.$inferInsert;
|
||||||
export type Setting = typeof settings.$inferSelect;
|
export type Setting = typeof settings.$inferSelect;
|
||||||
export type NewSetting = typeof settings.$inferInsert;
|
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 PostLink = typeof postLinks.$inferSelect;
|
||||||
export type NewPostLink = typeof postLinks.$inferInsert;
|
export type NewPostLink = typeof postLinks.$inferInsert;
|
||||||
export type PostMediaLink = typeof postMedia.$inferSelect;
|
export type PostMediaLink = typeof postMedia.$inferSelect;
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as crypto from 'crypto';
|
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 { 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 DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||||
const MIN_MAX_POSTS_PER_PAGE = 1;
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||||
@@ -15,8 +25,13 @@ export interface BlogGenerationOptions {
|
|||||||
dataDir: string;
|
dataDir: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
maxPostsPerPage?: number;
|
maxPostsPerPage?: number;
|
||||||
|
language?: string;
|
||||||
|
pageTitle?: string;
|
||||||
|
sections?: BlogGenerationSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BlogGenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date';
|
||||||
|
|
||||||
export interface BlogGenerationResult {
|
export interface BlogGenerationResult {
|
||||||
path: string;
|
path: string;
|
||||||
urlCount: number;
|
urlCount: number;
|
||||||
@@ -25,6 +40,7 @@ export interface BlogGenerationResult {
|
|||||||
tagCount: number;
|
tagCount: number;
|
||||||
categoryCount: number;
|
categoryCount: number;
|
||||||
archiveCount: number;
|
archiveCount: number;
|
||||||
|
pagesGenerated: number;
|
||||||
feeds: {
|
feeds: {
|
||||||
rssPath: string;
|
rssPath: string;
|
||||||
atomPath: string;
|
atomPath: string;
|
||||||
@@ -145,52 +161,46 @@ function computeContentHash(content: string): string {
|
|||||||
return crypto.createHash('sha256').update(content).digest('hex');
|
return crypto.createHash('sha256').update(content).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHashSettingValue(key: string): Promise<string | null> {
|
async function writeFileIfHashChanged(projectId: string, filePath: string, relativePath: string, content: string): Promise<boolean> {
|
||||||
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> {
|
|
||||||
const hash = computeContentHash(content);
|
const hash = computeContentHash(content);
|
||||||
const previousHash = await getHashSettingValue(hashKey);
|
const previousHash = await getGeneratedFileHash(projectId, relativePath);
|
||||||
if (previousHash === hash) {
|
if (previousHash === hash) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
await fs.writeFile(filePath, content, 'utf-8');
|
||||||
await setHashSettingValue(hashKey, hash);
|
await setGeneratedFileHash(projectId, relativePath, hash);
|
||||||
return true;
|
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 {
|
export class BlogGenerationEngine {
|
||||||
private readonly postEngine = getPostEngine();
|
private readonly postEngine = getPostEngine();
|
||||||
|
private readonly mediaEngine = getMediaEngine();
|
||||||
|
private readonly postMediaEngine = getPostMediaEngine();
|
||||||
|
|
||||||
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
|
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
|
||||||
onProgress(0, 'Loading posts...');
|
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 maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||||
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
|
const publishedCandidates = await this.postEngine.getPostsFiltered({ status: 'published' });
|
||||||
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
|
const draftCandidates = await this.postEngine.getPostsFiltered({ status: 'draft' });
|
||||||
@@ -219,7 +229,7 @@ export class BlogGenerationEngine {
|
|||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
const feedPosts = publishedPosts.slice(0, maxPostsPerPage);
|
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 now = new Date().toISOString();
|
||||||
const allTags = new Set<string>();
|
const allTags = new Set<string>();
|
||||||
@@ -258,7 +268,7 @@ export class BlogGenerationEngine {
|
|||||||
|
|
||||||
const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now;
|
const latestPostUpdatedAt = publishedPosts[0]?.updatedAt.toISOString() || now;
|
||||||
|
|
||||||
onProgress(40, 'Building sitemap XML...');
|
onProgress(5, 'Building sitemap XML...');
|
||||||
|
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
|
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'));
|
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])) {
|
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'));
|
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'));
|
urls.push(buildSitemapUrl(`${options.baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress(70, 'Adding category pages...');
|
|
||||||
for (const category of Array.from(allCategories).sort()) {
|
for (const category of Array.from(allCategories).sort()) {
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
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()) {
|
for (const tag of Array.from(allTags).sort()) {
|
||||||
urls.push(buildSitemapUrl(`${options.baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6'));
|
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 = [
|
const sitemapXml = [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
@@ -382,22 +389,93 @@ export class BlogGenerationEngine {
|
|||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
onProgress(92, 'Writing sitemap and feeds...');
|
|
||||||
|
|
||||||
const htmlDir = path.join(options.dataDir, 'html');
|
const htmlDir = path.join(options.dataDir, 'html');
|
||||||
await fs.mkdir(htmlDir, { recursive: true });
|
await fs.mkdir(htmlDir, { recursive: true });
|
||||||
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
|
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
|
||||||
const rssPath = path.join(htmlDir, 'rss.xml');
|
const rssPath = path.join(htmlDir, 'rss.xml');
|
||||||
const atomPath = path.join(htmlDir, 'atom.xml');
|
const atomPath = path.join(htmlDir, 'atom.xml');
|
||||||
const hashKeyPrefix = `project:${options.projectId}:generation-hash`;
|
|
||||||
|
|
||||||
const [sitemapWritten, rssWritten, atomWritten] = await Promise.all([
|
const estimatedUnitsBySection = this.estimateGenerationUnitsBySection(
|
||||||
writeFileIfHashChanged(sitemapPath, sitemapXml, `${hashKeyPrefix}:sitemap.xml`),
|
publishedPosts,
|
||||||
writeFileIfHashChanged(rssPath, rssXml, `${hashKeyPrefix}:rss.xml`),
|
allCategories,
|
||||||
writeFileIfHashChanged(atomPath, atomXml, `${hashKeyPrefix}:atom.xml`),
|
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 {
|
return {
|
||||||
path: sitemapPath,
|
path: sitemapPath,
|
||||||
@@ -407,6 +485,7 @@ export class BlogGenerationEngine {
|
|||||||
tagCount: allTags.size,
|
tagCount: allTags.size,
|
||||||
categoryCount: allCategories.size,
|
categoryCount: allCategories.size,
|
||||||
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
|
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
|
||||||
|
pagesGenerated,
|
||||||
feeds: {
|
feeds: {
|
||||||
rssPath,
|
rssPath,
|
||||||
atomPath,
|
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;
|
let blogGenerationEngine: BlogGenerationEngine | null = null;
|
||||||
|
|||||||
812
src/main/engine/PageRenderer.ts
Normal file
812
src/main/engine/PageRenderer.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -4,12 +4,15 @@ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cance
|
|||||||
|
|
||||||
export interface TaskProgress {
|
export interface TaskProgress {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
name: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
progress: number; // 0-100
|
progress: number; // 0-100
|
||||||
message: string;
|
message: string;
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime?: Date;
|
endTime?: Date;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
groupId?: string;
|
||||||
|
groupName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Task<T = unknown> {
|
export interface Task<T = unknown> {
|
||||||
@@ -17,6 +20,8 @@ export interface Task<T = unknown> {
|
|||||||
name: string;
|
name: string;
|
||||||
execute: (onProgress: (progress: number, message: string) => void) => Promise<T>;
|
execute: (onProgress: (progress: number, message: string) => void) => Promise<T>;
|
||||||
cancel?: () => void;
|
cancel?: () => void;
|
||||||
|
groupId?: string;
|
||||||
|
groupName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TaskManager extends EventEmitter {
|
export class TaskManager extends EventEmitter {
|
||||||
@@ -44,10 +49,13 @@ export class TaskManager extends EventEmitter {
|
|||||||
async runTask<T>(task: Task<T>): Promise<T> {
|
async runTask<T>(task: Task<T>): Promise<T> {
|
||||||
const progress: TaskProgress = {
|
const progress: TaskProgress = {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
|
name: task.name,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: 'Waiting to start...',
|
message: 'Waiting to start...',
|
||||||
startTime: new Date(),
|
startTime: new Date(),
|
||||||
|
groupId: task.groupId,
|
||||||
|
groupName: task.groupName,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.tasks.set(task.id, progress);
|
this.tasks.set(task.id, progress);
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import { dialog } from 'electron';
|
|||||||
import { getPostEngine } from '../engine/PostEngine';
|
import { getPostEngine } from '../engine/PostEngine';
|
||||||
import { getProjectEngine } from '../engine/ProjectEngine';
|
import { getProjectEngine } from '../engine/ProjectEngine';
|
||||||
import { getMetaEngine } from '../engine/MetaEngine';
|
import { getMetaEngine } from '../engine/MetaEngine';
|
||||||
|
import { getMediaEngine } from '../engine/MediaEngine';
|
||||||
|
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||||
import { taskManager } from '../engine/TaskManager';
|
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;
|
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||||
|
|
||||||
@@ -12,6 +20,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
const projectEngine = getProjectEngine();
|
const projectEngine = getProjectEngine();
|
||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = getMetaEngine();
|
||||||
|
const mediaEngine = getMediaEngine();
|
||||||
|
const postMediaEngine = getPostMediaEngine();
|
||||||
const blogGenerationEngine = getBlogGenerationEngine();
|
const blogGenerationEngine = getBlogGenerationEngine();
|
||||||
|
|
||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
@@ -22,6 +32,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
metaEngine.setProjectContext(project.id, dataDir);
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
|
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||||
|
postMediaEngine.setProjectContext(project.id);
|
||||||
|
|
||||||
if (!metaEngine.isInitialized()) {
|
if (!metaEngine.isInitialized()) {
|
||||||
await metaEngine.syncOnStartup();
|
await metaEngine.syncOnStartup();
|
||||||
@@ -33,27 +45,90 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
await dialog.showMessageBox({
|
await dialog.showMessageBox({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
title: 'Public URL Required',
|
title: 'Public URL Required',
|
||||||
message: 'Sitemap generation requires a public URL.',
|
message: 'Site rendering requires a public URL.',
|
||||||
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
|
detail: 'Set Project → Public URL in Settings before rendering the site.',
|
||||||
});
|
});
|
||||||
throw new Error('Project public URL is not configured');
|
throw new Error('Project public URL is not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskId = `sitemap-generate-${Date.now()}`;
|
const taskTimestamp = Date.now();
|
||||||
|
const taskGroupId = `site-render-${taskTimestamp}`;
|
||||||
return taskManager.runTask({
|
const taskGroupName = 'Render Site';
|
||||||
id: taskId,
|
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||||
name: 'Generate Sitemap',
|
const pageTitle = resolvePageTitle(metadata, project.name, project.description ?? undefined);
|
||||||
execute: async (onProgress) => {
|
const baseOptions = {
|
||||||
return blogGenerationEngine.generate({
|
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: metadata?.name?.trim() || project.name,
|
projectName: metadata?.name?.trim() || project.name,
|
||||||
projectDescription: metadata?.description,
|
projectDescription: metadata?.description,
|
||||||
dataDir,
|
dataDir,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
maxPostsPerPage: metadata?.maxPostsPerPage,
|
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 || ''));
|
}, (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]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,12 +122,15 @@ export interface MediaSearchResult {
|
|||||||
|
|
||||||
export interface TaskProgress {
|
export interface TaskProgress {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
name: string;
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
progress: number;
|
progress: number;
|
||||||
message: string;
|
message: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
groupId?: string;
|
||||||
|
groupName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncConfig {
|
export interface SyncConfig {
|
||||||
@@ -598,6 +601,7 @@ export interface ElectronAPI {
|
|||||||
tagCount: number;
|
tagCount: number;
|
||||||
categoryCount: number;
|
categoryCount: number;
|
||||||
archiveCount: number;
|
archiveCount: number;
|
||||||
|
pagesGenerated: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
chat: {
|
chat: {
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Task events
|
// Task events
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('task:created', (task: unknown) => {
|
||||||
|
const t = task as TaskProgress;
|
||||||
|
updateTask(t.taskId, t);
|
||||||
|
}) || (() => {})
|
||||||
|
);
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('task:started', (task: unknown) => {
|
window.electronAPI?.on('task:started', (task: unknown) => {
|
||||||
const t = task as TaskProgress;
|
const t = task as TaskProgress;
|
||||||
|
|||||||
@@ -86,6 +86,35 @@
|
|||||||
gap: 4px;
|
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 {
|
.task-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -131,20 +160,49 @@
|
|||||||
min-width: 0;
|
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;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.task-progress-bar {
|
||||||
|
flex: 1;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background-color: var(--vscode-input-background);
|
background-color: var(--vscode-input-background);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin-top: 4px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-progress-value {
|
||||||
|
min-width: 32px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.task-progress-fill {
|
.task-progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--vscode-focusBorder);
|
background-color: var(--vscode-focusBorder);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import type { TaskProgress } from '../../../main/shared/electronApi';
|
||||||
import './Panel.css';
|
import './Panel.css';
|
||||||
|
|
||||||
function getPostRelativePath(createdAt: string, slug: string): string | null {
|
function getPostRelativePath(createdAt: string, slug: string): string | null {
|
||||||
@@ -33,6 +34,72 @@ function toRelativePath(absolutePath: string, projectPath: string): string {
|
|||||||
return normalizedAbsolute;
|
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 = () => {
|
export const Panel: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
panelVisible,
|
panelVisible,
|
||||||
@@ -48,6 +115,7 @@ export const Panel: React.FC = () => {
|
|||||||
setSelectedPost,
|
setSelectedPost,
|
||||||
setActiveView,
|
setActiveView,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
const [collapsedTaskGroups, setCollapsedTaskGroups] = useState<Set<string>>(new Set());
|
||||||
const [gitLogLoading, setGitLogLoading] = useState(false);
|
const [gitLogLoading, setGitLogLoading] = useState(false);
|
||||||
const [gitLogError, setGitLogError] = useState<string | null>(null);
|
const [gitLogError, setGitLogError] = useState<string | null>(null);
|
||||||
const [postLinksLoading, setPostLinksLoading] = useState(false);
|
const [postLinksLoading, setPostLinksLoading] = useState(false);
|
||||||
@@ -69,6 +137,7 @@ export const Panel: React.FC = () => {
|
|||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
const recentTasks = tasks.slice(-10).reverse();
|
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 activeEditorTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
|
||||||
const canActivatePostLinks = activeEditorTab?.type === 'post';
|
const canActivatePostLinks = activeEditorTab?.type === 'post';
|
||||||
const canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media';
|
const canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media';
|
||||||
@@ -230,6 +299,52 @@ export const Panel: React.FC = () => {
|
|||||||
setActiveView('posts');
|
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 (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
@@ -292,35 +407,28 @@ export const Panel: React.FC = () => {
|
|||||||
<div className="panel-empty">No recent tasks</div>
|
<div className="panel-empty">No recent tasks</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="task-list">
|
<div className="task-list">
|
||||||
{recentTasks.map(task => (
|
{recentTaskEntries.map((entry) => {
|
||||||
<div key={task.taskId} className={`task-item status-${task.status}`}>
|
if (entry.kind === 'single') {
|
||||||
<div className="task-status">
|
return renderTaskRow(entry.task);
|
||||||
{task.status === 'running' && <span className="task-spinner" />}
|
}
|
||||||
{task.status === 'completed' && <span className="task-check">✓</span>}
|
|
||||||
{task.status === 'failed' && <span className="task-error">✗</span>}
|
const expanded = !collapsedTaskGroups.has(entry.groupId);
|
||||||
{task.status === 'pending' && <span className="task-pending">○</span>}
|
return (
|
||||||
</div>
|
<div key={entry.groupId} className="task-group-row">
|
||||||
<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' && (
|
|
||||||
<button
|
<button
|
||||||
className="task-cancel"
|
type="button"
|
||||||
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
|
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>
|
</button>
|
||||||
)}
|
{expanded && entry.tasks.map((task) => renderTaskRow(task, true))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -108,6 +108,41 @@
|
|||||||
background-color: var(--vscode-list-hoverBackground);
|
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 {
|
.task-item-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -1,10 +1,78 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import type { TaskProgress } from '../../../main/shared/electronApi';
|
||||||
import './TaskPopup.css';
|
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 = () => {
|
export const TaskPopup: React.FC = () => {
|
||||||
const { tasks } = useAppStore();
|
const { tasks } = useAppStore();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
const popupRef = useRef<HTMLDivElement>(null);
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const runningTasks = tasks.filter(t => t.status === 'running');
|
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 hasActiveTasks = runningTasks.length > 0 || pendingTasks.length > 0;
|
||||||
|
|
||||||
|
const runningEntries = buildTaskEntries(runningTasks);
|
||||||
|
const pendingEntries = buildTaskEntries(pendingTasks);
|
||||||
|
const recentEntries = buildTaskEntries(recentTasks);
|
||||||
|
|
||||||
// Close popup when clicking outside
|
// Close popup when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
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) {
|
if (!hasActiveTasks && recentTasks.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -107,74 +247,21 @@ export const TaskPopup: React.FC = () => {
|
|||||||
{runningTasks.length > 0 && (
|
{runningTasks.length > 0 && (
|
||||||
<div className="task-section">
|
<div className="task-section">
|
||||||
<div className="task-section-title">Running</div>
|
<div className="task-section-title">Running</div>
|
||||||
{runningTasks.map(task => (
|
{renderEntries(runningEntries)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingTasks.length > 0 && (
|
{pendingTasks.length > 0 && (
|
||||||
<div className="task-section">
|
<div className="task-section">
|
||||||
<div className="task-section-title">Pending</div>
|
<div className="task-section-title">Pending</div>
|
||||||
{pendingTasks.map(task => (
|
{renderEntries(pendingEntries)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recentTasks.length > 0 && (
|
{recentTasks.length > 0 && (
|
||||||
<div className="task-section">
|
<div className="task-section">
|
||||||
<div className="task-section-title">Recent</div>
|
<div className="task-section-title">Recent</div>
|
||||||
{recentTasks.map(task => (
|
{renderEntries(recentEntries)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
return { tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)) };
|
return { tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)) };
|
||||||
}
|
}
|
||||||
// Add new task if it doesn't exist yet
|
// 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
|
// Loading Actions
|
||||||
|
|||||||
514
tests/engine/BlogGenerationEngine.test.ts
Normal file
514
tests/engine/BlogGenerationEngine.test.ts
Normal 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/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -175,7 +175,7 @@ const mockTaskManager = {
|
|||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSettingsStore = new Map<string, string>();
|
const mockGeneratedFileHashStore = new Map<string, string>();
|
||||||
|
|
||||||
const mockDatabase = {
|
const mockDatabase = {
|
||||||
getLocal: vi.fn(() => ({
|
getLocal: vi.fn(() => ({
|
||||||
@@ -189,19 +189,23 @@ const mockDatabase = {
|
|||||||
})),
|
})),
|
||||||
getLocalClient: vi.fn(() => ({
|
getLocalClient: vi.fn(() => ({
|
||||||
execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
|
execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
|
||||||
if (sql.startsWith('SELECT value FROM settings WHERE key = ?')) {
|
if (sql.includes('CREATE TABLE IF NOT EXISTS generated_file_hashes')) {
|
||||||
const key = String(args?.[0] ?? '');
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.startsWith('SELECT content_hash FROM generated_file_hashes')) {
|
||||||
|
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
|
||||||
return {
|
return {
|
||||||
rows: mockSettingsStore.has(key)
|
rows: mockGeneratedFileHashStore.has(key)
|
||||||
? [{ value: mockSettingsStore.get(key) as string }]
|
? [{ content_hash: mockGeneratedFileHashStore.get(key) as string }]
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sql.startsWith('INSERT INTO settings')) {
|
if (sql.includes('INSERT INTO generated_file_hashes')) {
|
||||||
const key = String(args?.[0] ?? '');
|
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
|
||||||
const value = String(args?.[1] ?? '');
|
const value = String(args?.[2] ?? '');
|
||||||
mockSettingsStore.set(key, value);
|
mockGeneratedFileHashStore.set(key, value);
|
||||||
return { rowsAffected: 1 };
|
return { rowsAffected: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +262,10 @@ vi.mock('../../src/main/database', () => ({
|
|||||||
getDatabase: vi.fn(() => mockDatabase),
|
getDatabase: vi.fn(() => mockDatabase),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/database/connection', () => ({
|
||||||
|
getDatabase: vi.fn(() => mockDatabase),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/main/engine/stemmer', () => ({
|
vi.mock('../../src/main/engine/stemmer', () => ({
|
||||||
isoToStemmerLanguage: vi.fn((iso: string) => iso === 'en' ? 'english' : 'german'),
|
isoToStemmerLanguage: vi.fn((iso: string) => iso === 'en' ? 'english' : 'german'),
|
||||||
}));
|
}));
|
||||||
@@ -294,7 +302,7 @@ describe('IPC Handlers', () => {
|
|||||||
// Clear all mocks
|
// Clear all mocks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
registeredHandlers.clear();
|
registeredHandlers.clear();
|
||||||
mockSettingsStore.clear();
|
mockGeneratedFileHashStore.clear();
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
|
|
||||||
// Import and register handlers fresh for each test
|
// Import and register handlers fresh for each test
|
||||||
@@ -1571,6 +1579,62 @@ describe('IPC Handlers', () => {
|
|||||||
// ============ Blog Handlers ============
|
// ============ Blog Handlers ============
|
||||||
describe('Blog Handlers', () => {
|
describe('Blog Handlers', () => {
|
||||||
describe('blog:generateSitemap', () => {
|
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 () => {
|
it('should call taskManager.runTask with sitemap generation task', async () => {
|
||||||
const mockProject = createMockProject({
|
const mockProject = createMockProject({
|
||||||
id: 'test-project',
|
id: 'test-project',
|
||||||
@@ -1644,11 +1708,11 @@ describe('IPC Handlers', () => {
|
|||||||
|
|
||||||
const result = await invokeHandler('blog:generateSitemap');
|
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(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.stringMatching(/^sitemap-generate-\d+$/),
|
id: expect.stringMatching(/^site-render-core-\d+$/),
|
||||||
name: 'Generate Sitemap',
|
name: 'Render Site Core',
|
||||||
execute: expect.any(Function),
|
execute: expect.any(Function),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1838,7 +1902,11 @@ describe('IPC Handlers', () => {
|
|||||||
vi.mocked(writeFile).mockClear();
|
vi.mocked(writeFile).mockClear();
|
||||||
await invokeHandler('blog:generateSitemap');
|
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 () => {
|
it('should throw error when no active project', async () => {
|
||||||
|
|||||||
@@ -234,4 +234,39 @@ describe('Panel', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('tab', { name: 'Output' })).toHaveAttribute('aria-selected', 'true');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
78
tests/renderer/components/TaskPopup.test.tsx
Normal file
78
tests/renderer/components/TaskPopup.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user