feat: render the menu

This commit is contained in:
2026-02-21 22:01:40 +01:00
parent 9dfada8466
commit 03e813f1a9
11 changed files with 271 additions and 10 deletions

View File

@@ -10,11 +10,14 @@ import {
PageRenderer,
PREVIEW_ASSETS,
PREVIEW_IMAGE_ASSETS,
buildTemplateMenuItems,
buildCanonicalPostPath,
type CategoryRenderSettings,
type HtmlRewriteContext,
type TemplateMenuItem,
} from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
import type { MenuDocument } from './MenuEngine';
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
const MIN_MAX_POSTS_PER_PAGE = 1;
@@ -31,6 +34,7 @@ export interface BlogGenerationOptions {
pageTitle?: string;
picoTheme?: PicoThemeName;
categorySettings?: Record<string, CategoryRenderSettings>;
menu?: MenuDocument;
sections?: BlogGenerationSection[];
}
@@ -654,6 +658,7 @@ export class BlogGenerationEngine {
const pageContext = {
page_title: pageTitle,
language,
menu_items: buildTemplateMenuItems(options.menu),
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
};
@@ -1269,6 +1274,7 @@ export class BlogGenerationEngine {
const pageContext = {
page_title: pageTitle,
language,
menu_items: buildTemplateMenuItems(options.menu),
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
};
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine);
@@ -1424,7 +1430,7 @@ export class BlogGenerationEngine {
posts: PostData[],
rewriteContext: HtmlRewriteContext,
htmlDir: string,
pageContext: { page_title: string; language: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[] },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
@@ -1483,7 +1489,7 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
@@ -1522,7 +1528,7 @@ export class BlogGenerationEngine {
posts: PostData[],
rewriteContext: HtmlRewriteContext,
htmlDir: string,
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
pageRenderer: PageRenderer,
onPageGenerated: (message: string) => void,
): Promise<number> {
@@ -1551,7 +1557,7 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
@@ -1602,7 +1608,7 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
@@ -1655,7 +1661,7 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,
@@ -1708,7 +1714,7 @@ export class BlogGenerationEngine {
rewriteContext: HtmlRewriteContext,
maxPostsPerPage: number,
htmlDir: string,
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
pageRenderer: PageRenderer,
categorySettings: Record<string, CategoryRenderSettings>,
onPageGenerated: (message: string) => void,

View File

@@ -3,6 +3,7 @@ import { marked } from 'marked';
import { Liquid } from 'liquidjs';
import type { MediaData } from './MediaEngine';
import type { PostData } from './PostEngine';
import type { MenuDocument, MenuItemData } from './MenuEngine';
import { PICO_THEME_NAMES } from '../shared/picoThemes';
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
@@ -50,6 +51,7 @@ export type DateArchiveContext = {
export interface PostListTemplateContext {
page_title: string;
language: string;
menu_items: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
is_date_archive: boolean;
@@ -78,6 +80,7 @@ export interface PostListTemplateContext {
export interface SinglePostTemplateContext {
page_title: string;
language: string;
menu_items: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
post: TemplatePostEntry;
@@ -88,12 +91,20 @@ export interface SinglePostTemplateContext {
export interface NotFoundTemplateContext {
page_title: string;
language: string;
menu_items?: TemplateMenuItem[];
not_found_message?: string;
not_found_back_label?: string;
pico_stylesheet_href?: string;
html_theme_attribute?: string;
}
export interface TemplateMenuItem {
title: string;
href: string;
has_children: boolean;
children: TemplateMenuItem[];
}
export interface RoutePagination {
pathname: string;
page: number;
@@ -260,6 +271,48 @@ export function parseIntegerParam(value: string | undefined): number | null {
return Number.isInteger(parsed) ? parsed : null;
}
function buildMenuItemHref(item: MenuItemData): string {
if (item.kind === 'home') {
return '/';
}
if (item.kind === 'category-archive') {
const categoryName = (item.categoryName || '').trim();
return categoryName.length > 0 ? `/category/${encodeURIComponent(categoryName)}/` : '/';
}
if (item.kind === 'page') {
const normalizedSlug = (item.pageSlug || '')
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0)
.map((segment) => encodeURIComponent(segment))
.join('/');
return normalizedSlug.length > 0 ? `/${normalizedSlug}/` : '/';
}
return '#';
}
function toTemplateMenuItem(item: MenuItemData): TemplateMenuItem {
const children = (Array.isArray(item.children) ? item.children : []).map((child) => toTemplateMenuItem(child));
return {
title: item.title,
href: buildMenuItemHref(item),
has_children: children.length > 0,
children,
};
}
export function buildTemplateMenuItems(menu: MenuDocument | null | undefined): TemplateMenuItem[] {
const items = menu?.items;
if (!Array.isArray(items)) {
return [];
}
return items.map((item) => toTemplateMenuItem(item));
}
export function normalizeMacroName(name: string): string {
if (name === 'photo_album') {
return 'photo_archive';
@@ -776,6 +829,7 @@ export class PageRenderer {
basePathname: string;
page_title: string;
language: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
pagination?: PaginationContext;
@@ -890,6 +944,7 @@ export class PageRenderer {
return {
page_title: options.page_title,
language: options.language,
menu_items: options.menu_items ?? [],
pico_stylesheet_href: options.pico_stylesheet_href,
html_theme_attribute: options.html_theme_attribute,
is_date_archive: options.routeKind === 'date',
@@ -945,6 +1000,7 @@ export class PageRenderer {
basePathname: string;
page_title: string;
language: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
pagination?: PaginationContext;
@@ -971,7 +1027,7 @@ export class PageRenderer {
async renderSinglePost(
post: PostData,
rewriteContext: HtmlRewriteContext,
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string; html_theme_attribute?: string },
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string; html_theme_attribute?: string },
postEngine?: PostEngineContract,
): Promise<string> {
const renderablePost = postEngine
@@ -979,6 +1035,7 @@ export class PageRenderer {
: post;
const context: SinglePostTemplateContext = {
...pageContext,
menu_items: pageContext.menu_items ?? [],
post: {
id: renderablePost.id,
title: renderablePost.title,

View File

@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
import { getMediaEngine, type MediaData } from './MediaEngine';
import { getMenuEngine, type MenuDocument } from './MenuEngine';
import { getPostMediaEngine } from './PostMediaEngine';
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
import { getProjectEngine } from './ProjectEngine';
@@ -10,6 +11,7 @@ import {
PageRenderer,
PREVIEW_ASSETS,
PREVIEW_IMAGE_ASSETS,
buildTemplateMenuItems,
buildCanonicalPostPath,
clampMaxPostsPerPage,
parseRoutePagination,
@@ -43,11 +45,17 @@ interface MetaEngineContract {
syncOnStartup?: () => Promise<void>;
}
interface MenuEngineContract {
getMenu: () => Promise<MenuDocument>;
setProjectContext: (projectId: string, dataDir?: string) => void;
}
interface PreviewServerDependencies {
postEngine: PostEngineContract;
mediaEngine: MediaEngineContract;
postMediaEngine: PostMediaEngineContract;
settingsEngine: MetaEngineContract;
menuEngine: MenuEngineContract;
getActiveProjectContext: () => Promise<ActiveProjectContext>;
}
@@ -56,6 +64,7 @@ export class PreviewServer {
private readonly mediaEngine: MediaEngineContract;
private readonly postMediaEngine: PostMediaEngineContract;
private readonly settingsEngine: MetaEngineContract;
private readonly menuEngine: MenuEngineContract;
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
private readonly pageRenderer: PageRenderer;
private server: Server | null = null;
@@ -66,6 +75,7 @@ export class PreviewServer {
this.mediaEngine = dependencies?.mediaEngine ?? getMediaEngine();
this.postMediaEngine = dependencies?.postMediaEngine ?? getPostMediaEngine();
this.settingsEngine = dependencies?.settingsEngine ?? getMetaEngine();
this.menuEngine = dependencies?.menuEngine ?? getMenuEngine();
this.getActiveProjectContext = dependencies?.getActiveProjectContext ?? (async () => {
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
@@ -165,12 +175,15 @@ export class PreviewServer {
this.mediaEngine.setProjectContext?.(context.projectId, context.dataDir, context.dataDir);
this.postMediaEngine.setProjectContext(context.projectId);
this.settingsEngine.setProjectContext(context.projectId, context.dataDir);
this.menuEngine.setProjectContext(context.projectId, context.dataDir);
if (this.settingsEngine.isInitialized && this.settingsEngine.syncOnStartup && !this.settingsEngine.isInitialized()) {
await this.settingsEngine.syncOnStartup();
}
const metadata = await this.settingsEngine.getProjectMetadata();
const menu = await this.menuEngine.getMenu().catch(() => ({ items: [] }));
const menuItems = buildTemplateMenuItems(menu);
const categorySettings = this.resolveCategorySettings(metadata);
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
const language = metadata?.mainLanguage?.trim() || 'en';
@@ -190,6 +203,7 @@ export class PreviewServer {
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
pageTitle,
language,
menuItems,
picoStylesheetHref,
htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined,
}, categorySettings, listExcludedCategories);
@@ -218,6 +232,7 @@ export class PreviewServer {
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, {
pageTitle,
language,
menuItems,
picoStylesheetHref,
htmlThemeAttribute: undefined,
}, categorySettings, listExcludedCategories, {
@@ -228,6 +243,7 @@ export class PreviewServer {
const notFoundHtml = await this.pageRenderer.renderNotFound({
page_title: '404 Not Found',
language,
menu_items: menuItems,
pico_stylesheet_href: picoStylesheetHref,
html_theme_attribute: undefined,
});
@@ -246,7 +262,7 @@ export class PreviewServer {
pathname: string,
maxPostsPerPage: number,
rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string },
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[],
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
@@ -274,6 +290,7 @@ export class PreviewServer {
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -287,6 +304,7 @@ export class PreviewServer {
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -303,6 +321,7 @@ export class PreviewServer {
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -316,6 +335,7 @@ export class PreviewServer {
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -332,6 +352,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -350,6 +371,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -384,6 +406,7 @@ export class PreviewServer {
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -407,6 +430,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -427,6 +451,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -445,6 +470,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -459,6 +485,7 @@ export class PreviewServer {
return this.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);
@@ -469,7 +496,7 @@ export class PreviewServer {
private async renderStylePreview(
rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string },
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string> {
@@ -482,6 +509,7 @@ export class PreviewServer {
return this.pageRenderer.renderNotFound({
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
});
@@ -496,6 +524,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
}, this.postEngine);

View File

@@ -0,0 +1,16 @@
<ul class="blog-menu-list">
{% for item in items %}
<li class="blog-menu-item{% if item.has_children %} blog-menu-item-with-children{% endif %}">
{% if item.href == '#' %}
<span class="blog-menu-link">{{ item.title }}</span>
{% else %}
<a class="blog-menu-link" href="{{ item.href }}">{{ item.title }}</a>
{% endif %}
{% if item.has_children %}
<div class="blog-menu-submenu">
{% render 'partials/menu-items', items: item.children %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,5 @@
{% if menu_items and menu_items.size > 0 %}
<nav class="blog-menu">
{% render 'partials/menu-items', items: menu_items %}
</nav>
{% endif %}

View File

@@ -2,6 +2,17 @@
:root { color-scheme: light dark; }
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; background: var(--pico-background-color, var(--background-color)); color: var(--pico-color, var(--color)); }
main { display: grid; gap: 1rem; }
.blog-menu { border-top: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-bottom: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: .4rem 0; margin: -.15rem 0 .2rem; }
.blog-menu-list { list-style: none; display: flex; flex-wrap: wrap; gap: .25rem .75rem; margin: 0; padding: 0; }
.blog-menu-item { position: relative; }
.blog-menu-link { display: inline-flex; align-items: center; color: var(--pico-muted-color, var(--muted-color)); text-decoration: none; font-size: .94rem; line-height: 1.4; padding: .2rem .1rem; }
.blog-menu-item-with-children > .blog-menu-link::after { content: '▾'; font-size: .7em; margin-left: .38rem; opacity: .72; }
.blog-menu-link:hover,
.blog-menu-link:focus-visible { color: var(--pico-color, var(--color)); text-decoration: underline; }
.blog-menu-submenu { position: absolute; top: calc(100% + .12rem); left: 0; min-width: 12rem; display: none; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-card-background-color, var(--card-background-color)); padding: .4rem; z-index: 10; }
.blog-menu-submenu .blog-menu-list { flex-direction: column; flex-wrap: nowrap; gap: .2rem; }
.blog-menu-item-with-children:hover > .blog-menu-submenu,
.blog-menu-item-with-children:focus-within > .blog-menu-submenu { display: block; }
.post { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: 1rem; background: var(--pico-card-background-color, var(--card-background-color)); }
.post iframe { width: 100%; min-height: 20rem; }
.macro-gallery, .macro-photo-archive, .macro-tag-cloud { border: 1px dashed var(--pico-muted-border-color, var(--muted-border-color)); padding: .75rem; margin: 1rem 0; }

View File

@@ -27,6 +27,8 @@
{% endif %}
{% endif %}
{% render 'partials/menu', menu_items: menu_items, language: language %}
<section class="post-list" data-template="post-list" data-list-page="{{ is_list_page }}" data-first-page="{{ is_first_page }}" data-last-page="{{ is_last_page }}">
{% for day_block in day_blocks %}
{% if day_block.show_date_marker %}

View File

@@ -5,6 +5,7 @@
<main>
<article class="single-post" data-template="single-post">
<h1>{{ post.title }}</h1>
{% render 'partials/menu', menu_items: menu_items, language: language %}
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
</article>
</main>

View File

@@ -4,6 +4,7 @@ import { getProjectEngine } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { getMediaEngine } from '../engine/MediaEngine';
import { getPostMediaEngine } from '../engine/PostMediaEngine';
import { getMenuEngine } from '../engine/MenuEngine';
import { taskManager } from '../engine/TaskManager';
import {
getBlogGenerationEngine,
@@ -24,6 +25,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
const metaEngine = getMetaEngine();
const mediaEngine = getMediaEngine();
const postMediaEngine = getPostMediaEngine();
const menuEngine = getMenuEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
@@ -35,12 +37,14 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
metaEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
postMediaEngine.setProjectContext(project.id);
menuEngine.setProjectContext(project.id, dataDir);
if (!metaEngine.isInitialized()) {
await metaEngine.syncOnStartup();
}
const metadata = await metaEngine.getProjectMetadata();
const menu = await menuEngine.getMenu();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
await dialog.showMessageBox({
@@ -66,6 +70,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
pageTitle,
picoTheme: metadata?.picoTheme,
categorySettings: (metadata as any)?.categorySettings,
menu,
};
};

View File

@@ -4,6 +4,7 @@ import path from 'node:path';
import { tmpdir } from 'node:os';
import type { PostData } from '../../src/main/engine/PostEngine';
import { resolveUiLanguageFromSystemLocale } from '../../src/main/shared/i18n';
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
const generatedFileHashes = new Map<string, string>();
const getGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string) => {
@@ -183,6 +184,7 @@ describe('BlogGenerationEngine', () => {
language: string;
pageTitle: string;
categorySettings: Record<string, { renderInLists: boolean; showTitle: boolean }>;
menu: MenuDocument;
}>,
) {
setupPosts(posts);
@@ -198,9 +200,70 @@ describe('BlogGenerationEngine', () => {
language: options?.language,
pageTitle: options?.pageTitle,
categorySettings: options?.categorySettings,
menu: options?.menu,
}, onProgress);
}
it('renders configured menu below h1 with nested submenu links on list and single pages', async () => {
const posts = [
makePost({
id: '1',
slug: 'hello-world',
title: 'Hello World',
categories: ['news'],
createdAt: new Date('2025-03-15T10:00:00Z'),
}),
makePost({
id: '2',
slug: 'about',
title: 'About',
categories: ['page'],
createdAt: new Date('2025-03-14T10:00:00Z'),
}),
];
await generate(posts, {
menu: {
items: [
{ id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] },
{ id: 'about', title: 'About', kind: 'page', pageSlug: 'about', children: [] },
{
id: 'sections',
title: 'Sections',
kind: 'submenu',
children: [
{ id: 'news', title: 'News', kind: 'category-archive', categoryName: 'news', children: [] },
],
},
],
},
});
const indexHtml = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
const singleHtml = await readFile(path.join(tempDir, 'html', '2025', '03', '15', 'hello-world', 'index.html'), 'utf-8');
expect(indexHtml).toContain('class="blog-menu"');
expect(indexHtml).toContain('href="/"');
expect(indexHtml).toContain('href="/about/"');
expect(indexHtml).toContain('href="/category/news/"');
expect(indexHtml).toContain('class="blog-menu-submenu"');
const listH1Index = indexHtml.indexOf('<h1 class="archive-heading"');
const listMenuIndex = indexHtml.indexOf('class="blog-menu"');
const listContentIndex = indexHtml.indexOf('<section class="post-list"');
expect(listH1Index).toBeGreaterThan(-1);
expect(listMenuIndex).toBeGreaterThan(listH1Index);
expect(listContentIndex).toBeGreaterThan(listMenuIndex);
expect(singleHtml).toContain('class="blog-menu"');
const singleH1Index = singleHtml.indexOf('<h1>Hello World</h1>');
const singleMenuIndex = singleHtml.indexOf('class="blog-menu"');
const singleContentIndex = singleHtml.indexOf('<div class="post">');
expect(singleH1Index).toBeGreaterThan(-1);
expect(singleMenuIndex).toBeGreaterThan(singleH1Index);
expect(singleContentIndex).toBeGreaterThan(singleMenuIndex);
});
it('copies all required asset files to html/assets/ and html/images/', async () => {
const result = await generate([]);

View File

@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import type { PostData, PostFilter } from '../../src/main/engine/PostEngine';
import { PreviewServer } from '../../src/main/engine/PreviewServer';
import { resolveUiLanguageFromSystemLocale } from '../../src/main/shared/i18n';
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
type PostEngineLike = {
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
@@ -19,6 +20,11 @@ type SettingsEngineLike = {
setProjectContext: (projectId: string, dataDir?: string) => void;
};
type MenuEngineLike = {
getMenu: () => Promise<MenuDocument>;
setProjectContext: (projectId: string, dataDir?: string) => void;
};
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-01-02T10:00:00.000Z');
const updatedAt = overrides.updatedAt ?? createdAt;
@@ -117,6 +123,15 @@ function makePostMediaEngine(linksByPostId: Record<string, Array<{ media: { id:
};
}
function makeMenuEngine(menu: MenuDocument): MenuEngineLike {
return {
setProjectContext: vi.fn(),
async getMenu(): Promise<MenuDocument> {
return menu;
},
};
}
describe('PreviewServer', () => {
let server: PreviewServer;
let tempDir: string | null = null;
@@ -162,6 +177,57 @@ describe('PreviewServer', () => {
expect(html).toContain('02.01.2025');
});
it('renders menu below h1 with nested submenu links', async () => {
const posts = [
makePost({ id: '1', slug: 'hello', title: 'Hello', categories: ['news'], createdAt: new Date('2025-01-03T10:00:00.000Z') }),
makePost({ id: '2', slug: 'about', title: 'About', categories: ['page'], createdAt: new Date('2025-01-02T10:00:00.000Z') }),
];
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
menuEngine: makeMenuEngine({
items: [
{ id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] },
{ id: 'about', title: 'About', kind: 'page', pageSlug: 'about', children: [] },
{
id: 'sections',
title: 'Sections',
kind: 'submenu',
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('class="blog-menu"');
expect(rootHtml).toContain('href="/"');
expect(rootHtml).toContain('href="/about/"');
expect(rootHtml).toContain('href="/category/news/"');
expect(rootHtml).toContain('class="blog-menu-submenu"');
const rootH1Index = rootHtml.indexOf('<h1 class="archive-heading"');
const rootMenuIndex = rootHtml.indexOf('class="blog-menu"');
const rootPostListIndex = rootHtml.indexOf('<section class="post-list"');
expect(rootH1Index).toBeGreaterThan(-1);
expect(rootMenuIndex).toBeGreaterThan(rootH1Index);
expect(rootPostListIndex).toBeGreaterThan(rootMenuIndex);
const singleHtml = await (await fetch(`${server.getBaseUrl()}/posts/hello`)).text();
const singleH1Index = singleHtml.indexOf('<h1>Hello</h1>');
const singleMenuIndex = singleHtml.indexOf('class="blog-menu"');
const singleTextIndex = singleHtml.indexOf('<div class="post">');
expect(singleH1Index).toBeGreaterThan(-1);
expect(singleMenuIndex).toBeGreaterThan(singleH1Index);
expect(singleTextIndex).toBeGreaterThan(singleMenuIndex);
});
it('uses local CSS/JS assets and serves them from the preview server', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),