feat: categories with titles
This commit is contained in:
@@ -184,6 +184,7 @@ describe('BlogGenerationEngine', () => {
|
||||
language: string;
|
||||
pageTitle: string;
|
||||
categorySettings: Record<string, { renderInLists: boolean; showTitle: boolean }>;
|
||||
categoryMetadata: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>;
|
||||
menu: MenuDocument;
|
||||
}>,
|
||||
) {
|
||||
@@ -200,6 +201,7 @@ describe('BlogGenerationEngine', () => {
|
||||
language: options?.language,
|
||||
pageTitle: options?.pageTitle,
|
||||
categorySettings: options?.categorySettings,
|
||||
categoryMetadata: options?.categoryMetadata,
|
||||
menu: options?.menu,
|
||||
}, onProgress);
|
||||
}
|
||||
@@ -292,6 +294,35 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(tagHtml).toContain('class="blog-menu"');
|
||||
});
|
||||
|
||||
it('renders category menu links with category metadata title while keeping category URL', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'news-post',
|
||||
title: 'News Post',
|
||||
categories: ['news'],
|
||||
createdAt: new Date('2025-03-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
|
||||
await generate(posts, {
|
||||
categoryMetadata: {
|
||||
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||
},
|
||||
menu: {
|
||||
items: [
|
||||
{ id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] },
|
||||
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const indexHtml = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
|
||||
expect(indexHtml).toContain('href="/category/news/"');
|
||||
expect(indexHtml).toContain('>Newsroom</a>');
|
||||
expect(indexHtml).not.toContain('>news</a>');
|
||||
});
|
||||
|
||||
it('copies all required asset files to html/assets/ and html/images/', async () => {
|
||||
const result = await generate([]);
|
||||
|
||||
@@ -367,6 +398,25 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(newsHtml).toContain('data-template="post-list"');
|
||||
});
|
||||
|
||||
it('uses category title in rendered archive heading while keeping category name in URL path', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'news-1', title: 'News 1', categories: ['news'] }),
|
||||
];
|
||||
|
||||
await generate(posts, {
|
||||
categoryMetadata: {
|
||||
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||
},
|
||||
});
|
||||
|
||||
const newsPath = path.join(tempDir, 'html', 'category', 'news', 'index.html');
|
||||
expect(await fileExists(newsPath)).toBe(true);
|
||||
|
||||
const newsHtml = await readFile(newsPath, 'utf-8');
|
||||
expect(newsHtml).toContain('<h1 class="archive-heading">Newsroom</h1>');
|
||||
expect(newsHtml).not.toContain('<h1 class="archive-heading">news</h1>');
|
||||
});
|
||||
|
||||
it('generates tag pages with correct archive context', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'tagged-1', title: 'Tagged 1', tags: ['javascript'] }),
|
||||
|
||||
@@ -593,23 +593,23 @@ describe('MetaEngine', () => {
|
||||
expect(parsed.picoTheme).toBe('slate');
|
||||
});
|
||||
|
||||
it('should apply default category settings for standard categories', async () => {
|
||||
it('should apply default category metadata for standard categories', async () => {
|
||||
await metaEngine.setProjectMetadata({
|
||||
name: 'Category Defaults Project',
|
||||
} as any);
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||
expect(metadata.categorySettings).toEqual(
|
||||
expect(metadata.categoryMetadata).toEqual(
|
||||
expect.objectContaining({
|
||||
article: { renderInLists: true, showTitle: true },
|
||||
picture: { renderInLists: true, showTitle: true },
|
||||
aside: { renderInLists: true, showTitle: false },
|
||||
page: { renderInLists: false, showTitle: true },
|
||||
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist category settings to project.json', async () => {
|
||||
it('should persist legacy categorySettings input to category-meta.json', async () => {
|
||||
await metaEngine.setProjectMetadata({
|
||||
name: 'Persisted Category Settings',
|
||||
categorySettings: {
|
||||
@@ -621,20 +621,47 @@ describe('MetaEngine', () => {
|
||||
} as any);
|
||||
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||
const content = mockFiles.get(projectPath);
|
||||
const categoryMetaPath = normalizePath(`${metaDir}/category-meta.json`);
|
||||
const content = mockFiles.get(categoryMetaPath);
|
||||
const parsed = JSON.parse(content!);
|
||||
|
||||
expect(parsed.categorySettings).toEqual(
|
||||
expect(parsed).toEqual(
|
||||
expect.objectContaining({
|
||||
custom: { renderInLists: false, showTitle: true },
|
||||
aside: { renderInLists: true, showTitle: false },
|
||||
page: { renderInLists: false, showTitle: true },
|
||||
custom: { renderInLists: false, showTitle: true, title: 'custom' },
|
||||
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge missing category settings with defaults when loading from filesystem', async () => {
|
||||
it('should persist category metadata to category-meta.json and not to project.json', async () => {
|
||||
await metaEngine.setProjectMetadata({
|
||||
name: 'Persisted Category Metadata',
|
||||
categoryMetadata: {
|
||||
article: { renderInLists: true, showTitle: true, title: 'Articles' },
|
||||
updates: { renderInLists: false, showTitle: true, title: 'Project Updates' },
|
||||
},
|
||||
} as any);
|
||||
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||
const categoryMetaPath = normalizePath(`${metaDir}/category-meta.json`);
|
||||
|
||||
const projectContent = mockFiles.get(projectPath);
|
||||
const categoryMetaContent = mockFiles.get(categoryMetaPath);
|
||||
|
||||
expect(projectContent).toBeDefined();
|
||||
expect(categoryMetaContent).toBeDefined();
|
||||
|
||||
const parsedProject = JSON.parse(projectContent!);
|
||||
const parsedCategoryMeta = JSON.parse(categoryMetaContent!);
|
||||
|
||||
expect(parsedProject.categorySettings).toBeUndefined();
|
||||
expect(parsedProject.categoryMetadata).toBeUndefined();
|
||||
expect(parsedCategoryMeta.updates).toEqual({ renderInLists: false, showTitle: true, title: 'Project Updates' });
|
||||
});
|
||||
|
||||
it('should merge missing category settings with defaults when loading legacy project metadata', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||
mockFiles.set(projectPath, JSON.stringify({
|
||||
@@ -647,12 +674,12 @@ describe('MetaEngine', () => {
|
||||
await metaEngine.loadProjectMetadata();
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||
expect(metadata.categorySettings).toEqual(
|
||||
expect(metadata.categoryMetadata).toEqual(
|
||||
expect.objectContaining({
|
||||
custom: { renderInLists: false, showTitle: false },
|
||||
article: { renderInLists: true, showTitle: true },
|
||||
aside: { renderInLists: true, showTitle: false },
|
||||
page: { renderInLists: false, showTitle: true },
|
||||
custom: { renderInLists: false, showTitle: false, title: 'custom' },
|
||||
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -794,6 +821,46 @@ describe('MetaEngine', () => {
|
||||
expect(metadata?.description).toBe('Synced description');
|
||||
});
|
||||
|
||||
it('should load category metadata from category-meta.json during syncOnStartup', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['news']));
|
||||
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
|
||||
name: 'Synced Project',
|
||||
}));
|
||||
mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), JSON.stringify({
|
||||
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||
}));
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||
expect(metadata?.categoryMetadata?.news).toEqual({ renderInLists: true, showTitle: true, title: 'Newsroom' });
|
||||
});
|
||||
|
||||
it('should preserve customized category titles from category-meta.json across syncOnStartup', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['article', 'picture', 'aside', 'page', 'news']));
|
||||
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
|
||||
name: 'Synced Project',
|
||||
}));
|
||||
mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), JSON.stringify({
|
||||
article: { renderInLists: true, showTitle: true, title: 'Articles' },
|
||||
picture: { renderInLists: true, showTitle: true, title: 'Photos' },
|
||||
aside: { renderInLists: true, showTitle: false, title: 'Asides' },
|
||||
page: { renderInLists: false, showTitle: true, title: 'Pages' },
|
||||
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||
}));
|
||||
|
||||
await metaEngine.syncOnStartup();
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||
expect(metadata?.categoryMetadata?.article?.title).toBe('Articles');
|
||||
expect(metadata?.categoryMetadata?.picture?.title).toBe('Photos');
|
||||
expect(metadata?.categoryMetadata?.aside?.title).toBe('Asides');
|
||||
expect(metadata?.categoryMetadata?.page?.title).toBe('Pages');
|
||||
expect(metadata?.categoryMetadata?.news?.title).toBe('Newsroom');
|
||||
});
|
||||
|
||||
it('should create project.json with data from database during syncOnStartup if file does not exist', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||
|
||||
@@ -254,6 +254,41 @@ describe('PreviewServer', () => {
|
||||
expect(tagHtml).toContain('class="blog-menu"');
|
||||
});
|
||||
|
||||
it('renders category menu link labels from category metadata title', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'news-post', title: 'News Post', categories: ['news'], createdAt: new Date('2025-01-03T10:00:00.000Z') }),
|
||||
];
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine(posts),
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return {
|
||||
maxPostsPerPage: 50,
|
||||
categoryMetadata: {
|
||||
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||
},
|
||||
} as any;
|
||||
},
|
||||
} as any,
|
||||
menuEngine: makeMenuEngine({
|
||||
items: [
|
||||
{ id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] },
|
||||
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||
],
|
||||
}),
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const rootHtml = await (await fetch(`${server.getBaseUrl()}/`)).text();
|
||||
expect(rootHtml).toContain('href="/category/news/"');
|
||||
expect(rootHtml).toContain('>Newsroom</a>');
|
||||
expect(rootHtml).not.toContain('>news</a>');
|
||||
});
|
||||
|
||||
it('uses local CSS/JS assets and serves them from the preview server', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
|
||||
@@ -118,6 +118,7 @@ const mockMetaEngine = {
|
||||
removeCategory: vi.fn(),
|
||||
getProjectMetadata: vi.fn(),
|
||||
setProjectMetadata: vi.fn(),
|
||||
updateProjectMetadata: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTagEngine = {
|
||||
@@ -1222,6 +1223,23 @@ describe('IPC Handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta:getCategories', () => {
|
||||
it('should set context and sync before returning categories when uninitialized', async () => {
|
||||
const activeProject = createMockProject({ id: 'project-cats', dataPath: '/cats/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/resolved/cats-data');
|
||||
mockMetaEngine.isInitialized.mockReturnValue(false);
|
||||
mockMetaEngine.syncOnStartup.mockResolvedValue(undefined);
|
||||
mockMetaEngine.getCategories.mockResolvedValue(['article', 'news', 'travel']);
|
||||
|
||||
const result = await invokeHandler('meta:getCategories');
|
||||
|
||||
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-cats', '/resolved/cats-data');
|
||||
expect(mockMetaEngine.syncOnStartup).toHaveBeenCalled();
|
||||
expect(result).toEqual(['article', 'news', 'travel']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta:getProjectMetadata', () => {
|
||||
it('should return project metadata', async () => {
|
||||
const metadata = { name: 'Test Blog', description: 'A test blog', mainLanguage: 'de' };
|
||||
@@ -1234,6 +1252,20 @@ describe('IPC Handlers', () => {
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should set meta engine context from active project before reading metadata', async () => {
|
||||
const activeProject = createMockProject({ id: 'project-ctx', dataPath: '/ctx/data' });
|
||||
const metadata = { name: 'Ctx Blog' };
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/resolved/ctx-data');
|
||||
mockMetaEngine.isInitialized.mockReturnValue(true);
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue(metadata);
|
||||
|
||||
const result = await invokeHandler('meta:getProjectMetadata');
|
||||
|
||||
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-ctx', '/resolved/ctx-data');
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should sync metadata before reading when engine is not initialized', async () => {
|
||||
const metadata = { name: 'Test Blog', mainLanguage: 'de', defaultAuthor: 'Max' };
|
||||
mockMetaEngine.isInitialized.mockReturnValue(false);
|
||||
@@ -1260,6 +1292,24 @@ describe('IPC Handlers', () => {
|
||||
expect(result).toEqual(newMetadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta:updateProjectMetadata', () => {
|
||||
it('should set meta engine context from active project before updating metadata', async () => {
|
||||
const activeProject = createMockProject({ id: 'project-update', dataPath: '/update/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/resolved/update-data');
|
||||
mockMetaEngine.updateProjectMetadata.mockResolvedValue(undefined);
|
||||
const updatedMetadata = { name: 'Updated' };
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue(updatedMetadata);
|
||||
|
||||
const updates = { defaultAuthor: 'Author Name' };
|
||||
const result = await invokeHandler('meta:updateProjectMetadata', updates);
|
||||
|
||||
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-update', '/resolved/update-data');
|
||||
expect(mockMetaEngine.updateProjectMetadata).toHaveBeenCalledWith(updates);
|
||||
expect(result).toEqual(updatedMetadata);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Menu Handlers ============
|
||||
|
||||
@@ -141,8 +141,8 @@ describe('SettingsView Diff Preferences', () => {
|
||||
|
||||
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categorySettings: expect.objectContaining({
|
||||
page: expect.objectContaining({ renderInLists: true, showTitle: true }),
|
||||
categoryMetadata: expect.objectContaining({
|
||||
page: expect.objectContaining({ renderInLists: true, showTitle: true, title: 'page' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user