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>