330 lines
15 KiB
TypeScript
330 lines
15 KiB
TypeScript
import type { MenuDocument } from './MenuEngine';
|
|
import type { ProjectMetadata } from './MetaEngine';
|
|
import { getPicoStylesheetHref, sanitizePicoTheme } from '../shared/picoThemes';
|
|
import {
|
|
buildTemplateMenuItems,
|
|
clampMaxPostsPerPage,
|
|
parseRoutePagination,
|
|
resolvePageTitle,
|
|
type PostEngineContract,
|
|
type CategoryRenderSettings,
|
|
type HtmlRewriteContext,
|
|
type PageRenderer,
|
|
} from './PageRenderer';
|
|
import type { CategoryMetadata } from './MetaEngine';
|
|
import type { PostData, PostFilter } from './PostEngine';
|
|
|
|
export interface SharedActiveProjectContext {
|
|
projectId: string;
|
|
dataDir?: string;
|
|
projectName?: string;
|
|
projectDescription?: string;
|
|
}
|
|
|
|
export interface SharedRouteRenderOptions {
|
|
projectContext: SharedActiveProjectContext;
|
|
metadata?: ProjectMetadata | null;
|
|
menu?: MenuDocument;
|
|
htmlRewriteContext?: HtmlRewriteContext;
|
|
skipContextSetup?: boolean;
|
|
maxPostsPerPage?: number;
|
|
requestTheme?: string | null;
|
|
htmlThemeAttribute?: string;
|
|
allowEmptyArchiveRender?: boolean;
|
|
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
|
|
}
|
|
|
|
export interface SharedRouteRenderServices<TCategoryMetadata> {
|
|
postEngine: {
|
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
|
};
|
|
mediaEngine: {
|
|
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
|
};
|
|
postMediaEngine: {
|
|
setProjectContext: (projectId: string) => void;
|
|
};
|
|
settingsEngine: {
|
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
|
isInitialized?: () => boolean;
|
|
syncOnStartup?: () => Promise<void>;
|
|
};
|
|
menuEngine: {
|
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
|
getMenu: () => Promise<MenuDocument>;
|
|
};
|
|
resolveCategoryMetadata: (metadata: ProjectMetadata | null) => Record<string, TCategoryMetadata>;
|
|
resolveCategorySettings: (metadata: ProjectMetadata | null) => Record<string, CategoryRenderSettings>;
|
|
resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[];
|
|
buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>;
|
|
resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise<Record<string, string>>;
|
|
resolveTagTemplateSettings?: (projectContext: SharedActiveProjectContext) => Promise<Record<string, { postTemplateSlug?: string | null }>>;
|
|
pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>;
|
|
postEngineForMacros?: PostEngineContract;
|
|
loadPublishedSnapshotsPage: (
|
|
filter: PostFilter,
|
|
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
|
) => Promise<{ posts: PostData[]; totalPosts: number }>;
|
|
loadPublishedSnapshots: (
|
|
filter: PostFilter,
|
|
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
|
) => Promise<PostData[]>;
|
|
loadPostsForDayPage: (
|
|
year: number,
|
|
month: number,
|
|
day: number,
|
|
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
|
) => Promise<{ posts: PostData[]; totalPosts: number }>;
|
|
findSinglePostBySlug: (
|
|
slug: string,
|
|
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
|
dateFilter?: { year: number; month: number; day?: number },
|
|
) => Promise<PostData | null>;
|
|
}
|
|
|
|
async function resolveRouteWithSharedServices(
|
|
pathname: string,
|
|
maxPostsPerPage: number,
|
|
rewriteContext: HtmlRewriteContext,
|
|
pageContext: {
|
|
pageTitle: string;
|
|
language: string;
|
|
menuItems: ReturnType<typeof buildTemplateMenuItems>;
|
|
picoStylesheetHref: string;
|
|
htmlThemeAttribute?: string;
|
|
},
|
|
categorySettings: Record<string, CategoryRenderSettings>,
|
|
categoryMetadata: Record<string, CategoryMetadata>,
|
|
tagColorByName: Record<string, string>,
|
|
tagTemplateSettings: Record<string, { postTemplateSlug?: string | null }>,
|
|
listExcludedCategories: string[],
|
|
services: SharedRouteRenderServices<CategoryMetadata>,
|
|
allowEmptyArchiveRender: boolean,
|
|
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
|
): Promise<string | null> {
|
|
const routePagination = parseRoutePagination(pathname);
|
|
if (!routePagination) {
|
|
return null;
|
|
}
|
|
|
|
const pagedPathname = routePagination.pathname;
|
|
const page = routePagination.page;
|
|
const pageOptions = {
|
|
maxPostsPerPage,
|
|
page,
|
|
};
|
|
|
|
if (pagedPathname === '/') {
|
|
const result = await services.loadPublishedSnapshotsPage({ status: 'published', excludeCategories: listExcludedCategories }, pageOptions);
|
|
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
|
archiveGrouping: true,
|
|
routeKind: 'date',
|
|
archiveContext: { kind: 'root' },
|
|
basePathname: pagedPathname,
|
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
|
categorySettings,
|
|
page_title: pageContext.pageTitle,
|
|
language: pageContext.language,
|
|
menu_items: pageContext.menuItems,
|
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
|
renderEmptyState: allowEmptyArchiveRender,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/);
|
|
if (tagMatch) {
|
|
const tag = tagMatch[1];
|
|
const result = await services.loadPublishedSnapshotsPage({ status: 'published', tags: [tag], excludeCategories: listExcludedCategories }, pageOptions);
|
|
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
|
archiveGrouping: true,
|
|
routeKind: 'non-date',
|
|
archiveContext: { kind: 'tag', name: tag },
|
|
basePathname: pagedPathname,
|
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
|
categorySettings,
|
|
page_title: pageContext.pageTitle,
|
|
language: pageContext.language,
|
|
menu_items: pageContext.menuItems,
|
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
|
renderEmptyState: allowEmptyArchiveRender,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
|
|
if (categoryMatch) {
|
|
const category = categoryMatch[1];
|
|
const categoryDisplayTitle = categoryMetadata[category]?.title?.trim() || category;
|
|
const result = await services.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions);
|
|
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
|
archiveGrouping: true,
|
|
routeKind: 'non-date',
|
|
archiveContext: { kind: 'category', name: categoryDisplayTitle },
|
|
basePathname: pagedPathname,
|
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
|
categorySettings,
|
|
page_title: pageContext.pageTitle,
|
|
language: pageContext.language,
|
|
menu_items: pageContext.menuItems,
|
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
|
renderEmptyState: allowEmptyArchiveRender,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
const daySlugMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/);
|
|
if (daySlugMatch) {
|
|
const year = Number(daySlugMatch[1]);
|
|
const month = Number(daySlugMatch[2]);
|
|
const day = Number(daySlugMatch[3]);
|
|
const slug = daySlugMatch[4];
|
|
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day });
|
|
if (!post) return null;
|
|
return services.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,
|
|
tag_color_by_name: tagColorByName,
|
|
tagSettings: tagTemplateSettings,
|
|
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
const dayMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
|
|
if (dayMatch) {
|
|
const year = Number(dayMatch[1]);
|
|
const month = Number(dayMatch[2]);
|
|
const day = Number(dayMatch[3]);
|
|
const result = await services.loadPostsForDayPage(year, month, day, {
|
|
...pageOptions,
|
|
excludeCategories: listExcludedCategories,
|
|
});
|
|
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
|
archiveGrouping: true,
|
|
routeKind: 'date',
|
|
archiveContext: { kind: 'day', year, month, day },
|
|
basePathname: pagedPathname,
|
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
|
categorySettings,
|
|
page_title: pageContext.pageTitle,
|
|
language: pageContext.language,
|
|
menu_items: pageContext.menuItems,
|
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
|
renderEmptyState: allowEmptyArchiveRender,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
const monthMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})$/);
|
|
if (monthMatch) {
|
|
const year = Number(monthMatch[1]);
|
|
const month = Number(monthMatch[2]);
|
|
if (month < 1 || month > 12) return null;
|
|
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions);
|
|
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
|
archiveGrouping: true,
|
|
routeKind: 'date',
|
|
archiveContext: { kind: 'month', year, month },
|
|
basePathname: pagedPathname,
|
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
|
categorySettings,
|
|
page_title: pageContext.pageTitle,
|
|
language: pageContext.language,
|
|
menu_items: pageContext.menuItems,
|
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
|
renderEmptyState: allowEmptyArchiveRender,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
const yearMatch = pagedPathname.match(/^\/(\d{4})$/);
|
|
if (yearMatch) {
|
|
const year = Number(yearMatch[1]);
|
|
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, excludeCategories: listExcludedCategories }, pageOptions);
|
|
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
|
archiveGrouping: true,
|
|
routeKind: 'date',
|
|
archiveContext: { kind: 'year', year },
|
|
basePathname: pagedPathname,
|
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
|
categorySettings,
|
|
page_title: pageContext.pageTitle,
|
|
language: pageContext.language,
|
|
menu_items: pageContext.menuItems,
|
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
|
renderEmptyState: allowEmptyArchiveRender,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
|
|
if (pageSlugMatch) {
|
|
const slug = pageSlugMatch[1];
|
|
const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
|
|
const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
|
|
if (!pagePost) return null;
|
|
return services.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,
|
|
tag_color_by_name: tagColorByName,
|
|
tagSettings: tagTemplateSettings,
|
|
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
|
}, services.postEngineForMacros);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
|
pathname: string,
|
|
options: SharedRouteRenderOptions,
|
|
services: SharedRouteRenderServices<TCategoryMetadata>,
|
|
): Promise<string | null> {
|
|
if (!options.skipContextSetup) {
|
|
services.postEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
|
|
services.mediaEngine.setProjectContext?.(options.projectContext.projectId, options.projectContext.dataDir, options.projectContext.dataDir);
|
|
services.postMediaEngine.setProjectContext(options.projectContext.projectId);
|
|
services.settingsEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
|
|
services.menuEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
|
|
}
|
|
|
|
let metadata = options.metadata;
|
|
if (metadata === undefined) {
|
|
if (services.settingsEngine.isInitialized && services.settingsEngine.syncOnStartup && !services.settingsEngine.isInitialized()) {
|
|
await services.settingsEngine.syncOnStartup();
|
|
}
|
|
metadata = await services.settingsEngine.getProjectMetadata();
|
|
}
|
|
|
|
const categoryMetadata = services.resolveCategoryMetadata(metadata ?? null);
|
|
const menu = options.menu ?? await services.menuEngine.getMenu().catch(() => ({ items: [] }));
|
|
const menuItems = buildTemplateMenuItems(menu, categoryMetadata as Record<string, { title?: string }>);
|
|
const categorySettings = services.resolveCategorySettings(metadata ?? null);
|
|
const listExcludedCategories = services.resolveListExcludedCategories(categorySettings);
|
|
const language = metadata?.mainLanguage?.trim() || 'en';
|
|
const pageTitle = resolvePageTitle(metadata ?? null, options.projectContext.projectName, options.projectContext.projectDescription);
|
|
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage ?? metadata?.maxPostsPerPage);
|
|
const appliedTheme = sanitizePicoTheme(options.requestTheme)
|
|
?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
|
|
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
|
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
|
|
const tagColorByName = await services.resolveTagColorByName(options.projectContext);
|
|
const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {};
|
|
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
|
|
|
|
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
|
|
pageTitle,
|
|
language,
|
|
menuItems,
|
|
picoStylesheetHref,
|
|
htmlThemeAttribute: options.htmlThemeAttribute,
|
|
}, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, tagTemplateSettings, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.allowEmptyArchiveRender === true, options.singlePostOptions);
|
|
}
|