feat: move to templates
This commit is contained in:
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { marked } from 'marked';
|
||||
import { Liquid } from 'liquidjs';
|
||||
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
|
||||
import { getMediaEngine, type MediaData } from './MediaEngine';
|
||||
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
|
||||
@@ -46,6 +47,53 @@ interface RoutePagination {
|
||||
page: number;
|
||||
}
|
||||
|
||||
interface PaginationContext {
|
||||
page: number;
|
||||
maxPostsPerPage: number;
|
||||
totalPosts: number;
|
||||
}
|
||||
|
||||
interface TemplatePostEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface DayBlockContext {
|
||||
date_label: string;
|
||||
show_date_marker: boolean;
|
||||
show_separator: boolean;
|
||||
posts: TemplatePostEntry[];
|
||||
}
|
||||
|
||||
interface PostListTemplateContext {
|
||||
page_title: string;
|
||||
language: string;
|
||||
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[];
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
interface NotFoundTemplateContext {
|
||||
page_title: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface MediaEngineContract {
|
||||
getAllMedia: () => Promise<MediaData[]>;
|
||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||
@@ -291,17 +339,6 @@ function renderMacro(name: string, params: Record<string, string>, postId: strin
|
||||
return '';
|
||||
}
|
||||
|
||||
async function renderPostHtml(post: PostData, rewriteContext: HtmlRewriteContext): Promise<string> {
|
||||
const withMacros = post.content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
|
||||
const params = parseMacroParams(rawParams);
|
||||
return renderMacro(macroName.toLowerCase(), params, post.id);
|
||||
});
|
||||
|
||||
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
|
||||
const rewrittenHtml = rewriteRenderedHtmlUrls(markdownHtml, rewriteContext);
|
||||
return `<div class="post">${rewrittenHtml}</div>`;
|
||||
}
|
||||
|
||||
function buildCanonicalPostPath(post: PostData): string {
|
||||
const year = post.createdAt.getFullYear();
|
||||
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
|
||||
@@ -323,37 +360,28 @@ function getArchiveDateKey(date: Date): string {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getPageHtml(content: string, title: string, language: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="${escapeHtml(language)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/assets/pico.min.css" />
|
||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||
main { display: grid; gap: 1rem; }
|
||||
.post { border: 1px solid var(--muted-border-color); padding: 1rem; background: var(--card-background-color); }
|
||||
.post iframe { width: 100%; min-height: 20rem; }
|
||||
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; }
|
||||
.archive-day-group { display: grid; grid-template-columns: 5.25rem 1fr; gap: 1.25rem; align-items: stretch; }
|
||||
.archive-day-marker { display: flex; justify-content: center; align-items: center; color: var(--muted-color); }
|
||||
.archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; }
|
||||
.archive-day-posts { display: grid; gap: 1rem; }
|
||||
.archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--color); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; }
|
||||
.archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; }
|
||||
</style>
|
||||
<script defer src="/assets/lightbox.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
function buildPaginationHref(basePathname: string, page: number): string {
|
||||
const base = basePathname === '/' ? '' : basePathname;
|
||||
if (page <= 1) {
|
||||
return basePathname === '/' ? '/' : `${basePathname}/`;
|
||||
}
|
||||
|
||||
return `${base}/page/${page}/`;
|
||||
}
|
||||
|
||||
function mapToRecord(map: Map<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(map.entries());
|
||||
}
|
||||
|
||||
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 PreviewServer {
|
||||
@@ -361,6 +389,7 @@ export class PreviewServer {
|
||||
private readonly mediaEngine: MediaEngineContract;
|
||||
private readonly settingsEngine: MetaEngineContract;
|
||||
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
||||
private readonly liquid: Liquid;
|
||||
private server: Server | null = null;
|
||||
private port: number | null = null;
|
||||
|
||||
@@ -380,6 +409,34 @@ export class PreviewServer {
|
||||
projectDescription: activeProject?.description ?? undefined,
|
||||
};
|
||||
});
|
||||
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 withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
|
||||
const params = parseMacroParams(rawParams);
|
||||
return renderMacro(macroName.toLowerCase(), params, postId);
|
||||
});
|
||||
|
||||
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
|
||||
return rewriteRenderedHtmlUrls(markdownHtml, rewriteContext);
|
||||
});
|
||||
}
|
||||
|
||||
async start(preferredPort = 0): Promise<number> {
|
||||
@@ -471,6 +528,8 @@ export class PreviewServer {
|
||||
}
|
||||
|
||||
const metadata = await this.settingsEngine.getProjectMetadata();
|
||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
|
||||
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
||||
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
||||
|
||||
@@ -495,21 +554,32 @@ export class PreviewServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext);
|
||||
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, {
|
||||
pageTitle,
|
||||
language,
|
||||
});
|
||||
if (!result) {
|
||||
this.respond(res, 404, 'Not Found');
|
||||
const notFoundHtml = await this.renderNotFound({
|
||||
page_title: '404 Not Found',
|
||||
language,
|
||||
});
|
||||
this.respond(res, 404, notFoundHtml);
|
||||
return;
|
||||
}
|
||||
|
||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||
this.respond(res, 200, getPageHtml(result, resolvePageTitle(metadata, context.projectName, context.projectDescription), language));
|
||||
this.respond(res, 200, result);
|
||||
} catch (error) {
|
||||
console.error('[PreviewServer] Request failed:', error);
|
||||
this.respond(res, 500, 'Internal Server Error');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRoute(pathname: string, maxPostsPerPage: number, rewriteContext: HtmlRewriteContext): Promise<string | null> {
|
||||
private async resolveRoute(
|
||||
pathname: string,
|
||||
maxPostsPerPage: number,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
pageContext: { pageTitle: string; language: string },
|
||||
): Promise<string | null> {
|
||||
const routePagination = parseRoutePagination(pathname);
|
||||
if (!routePagination) {
|
||||
return null;
|
||||
@@ -522,61 +592,91 @@ export class PreviewServer {
|
||||
page,
|
||||
};
|
||||
|
||||
const postsYearMonthSlugMatch = pagedPathname.match(/^\/posts\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
||||
if (postsYearMonthSlugMatch) {
|
||||
const year = Number(postsYearMonthSlugMatch[1]);
|
||||
const month = Number(postsYearMonthSlugMatch[2]);
|
||||
const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
||||
if (month < 1 || month > 12) return null;
|
||||
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
||||
if (!post) return null;
|
||||
return this.renderPostList([post], rewriteContext);
|
||||
}
|
||||
const postsYearMonthSlugMatch = pagedPathname.match(/^\/posts\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
||||
if (postsYearMonthSlugMatch) {
|
||||
const year = Number(postsYearMonthSlugMatch[1]);
|
||||
const month = Number(postsYearMonthSlugMatch[2]);
|
||||
const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
||||
if (month < 1 || month > 12) return null;
|
||||
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
||||
if (!post) return null;
|
||||
return this.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/);
|
||||
if (postsSlugMatch) {
|
||||
const slug = postsSlugMatch[1].replace(/\.html?$/i, '');
|
||||
const post = await this.findPublishedPostBySlug(slug);
|
||||
if (!post) return null;
|
||||
return this.renderPostList([post], rewriteContext);
|
||||
}
|
||||
const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/);
|
||||
if (postsSlugMatch) {
|
||||
const slug = postsSlugMatch[1].replace(/\.html?$/i, '');
|
||||
const post = await this.findPublishedPostBySlug(slug);
|
||||
if (!post) return null;
|
||||
return this.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyPostsYearMonthSlugMatch = pagedPathname.match(/^\/post\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
||||
if (legacyPostsYearMonthSlugMatch) {
|
||||
const year = Number(legacyPostsYearMonthSlugMatch[1]);
|
||||
const month = Number(legacyPostsYearMonthSlugMatch[2]);
|
||||
const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
||||
if (month < 1 || month > 12) return null;
|
||||
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
||||
if (!post) return null;
|
||||
return this.renderPostList([post], rewriteContext);
|
||||
}
|
||||
const legacyPostsYearMonthSlugMatch = pagedPathname.match(/^\/post\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
||||
if (legacyPostsYearMonthSlugMatch) {
|
||||
const year = Number(legacyPostsYearMonthSlugMatch[1]);
|
||||
const month = Number(legacyPostsYearMonthSlugMatch[2]);
|
||||
const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
||||
if (month < 1 || month > 12) return null;
|
||||
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
||||
if (!post) return null;
|
||||
return this.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/);
|
||||
if (legacyPostsSlugMatch) {
|
||||
const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, '');
|
||||
const post = await this.findPublishedPostBySlug(slug);
|
||||
if (!post) return null;
|
||||
return this.renderPostList([post], rewriteContext);
|
||||
}
|
||||
const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/);
|
||||
if (legacyPostsSlugMatch) {
|
||||
const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, '');
|
||||
const post = await this.findPublishedPostBySlug(slug);
|
||||
if (!post) return null;
|
||||
return this.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
if (pagedPathname === '/') {
|
||||
const posts = await this.loadPublishedSnapshots({ status: 'published' }, pageOptions);
|
||||
return this.renderPostList(posts, rewriteContext);
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published' }, pageOptions);
|
||||
return this.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: false,
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/);
|
||||
if (tagMatch) {
|
||||
const tag = tagMatch[1];
|
||||
const posts = await this.loadPublishedSnapshots({ status: 'published', tags: [tag] }, pageOptions);
|
||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', tags: [tag] }, pageOptions);
|
||||
return this.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
|
||||
if (categoryMatch) {
|
||||
const category = categoryMatch[1];
|
||||
const posts = await this.loadPublishedSnapshots({ status: 'published', categories: [category] }, pageOptions);
|
||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category] }, pageOptions);
|
||||
return this.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const daySlugMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})\/([^/]+)$/);
|
||||
@@ -588,7 +688,10 @@ export class PreviewServer {
|
||||
const posts = await this.loadPostsForDay(year, month, day);
|
||||
const post = posts.find((candidate) => candidate.slug === slug) || null;
|
||||
if (!post) return null;
|
||||
return this.renderPostList([post], rewriteContext);
|
||||
return this.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const dayMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
|
||||
@@ -596,8 +699,14 @@ export class PreviewServer {
|
||||
const year = Number(dayMatch[1]);
|
||||
const month = Number(dayMatch[2]);
|
||||
const day = Number(dayMatch[3]);
|
||||
const posts = await this.loadPostsForDay(year, month, day, pageOptions);
|
||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
||||
const result = await this.loadPostsForDayPage(year, month, day, pageOptions);
|
||||
return this.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const monthMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})$/);
|
||||
@@ -605,15 +714,27 @@ export class PreviewServer {
|
||||
const year = Number(monthMatch[1]);
|
||||
const month = Number(monthMatch[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
const posts = await this.loadPublishedSnapshots({ status: 'published', year, month: month - 1 }, pageOptions);
|
||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1 }, pageOptions);
|
||||
return this.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const yearMatch = pagedPathname.match(/^\/(\d{4})$/);
|
||||
if (yearMatch) {
|
||||
const year = Number(yearMatch[1]);
|
||||
const posts = await this.loadPublishedSnapshots({ status: 'published', year }, pageOptions);
|
||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year }, pageOptions);
|
||||
return this.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
|
||||
@@ -622,7 +743,10 @@ export class PreviewServer {
|
||||
const pages = await this.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
|
||||
const page = pages.find((candidate) => candidate.slug === slug) || null;
|
||||
if (!page) return null;
|
||||
return this.renderPostList([page], rewriteContext);
|
||||
return this.renderSinglePost(page, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -648,25 +772,40 @@ export class PreviewServer {
|
||||
day: number,
|
||||
pagination?: { maxPostsPerPage: number; page?: number },
|
||||
): Promise<PostData[]> {
|
||||
const result = await this.loadPostsForDayPage(year, month, day, pagination);
|
||||
return result.posts;
|
||||
}
|
||||
|
||||
private async loadPostsForDayPage(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
pagination?: { maxPostsPerPage: number; page?: number },
|
||||
): Promise<{ posts: PostData[]; totalPosts: number }> {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return [];
|
||||
return { posts: [], totalPosts: 0 };
|
||||
}
|
||||
|
||||
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||
|
||||
const posts = await this.loadPublishedSnapshots({
|
||||
const result = await this.loadPublishedSnapshotsPage({
|
||||
status: 'published',
|
||||
startDate,
|
||||
endDate,
|
||||
}, pagination);
|
||||
|
||||
return posts.filter((post) => {
|
||||
const posts = result.posts.filter((post) => {
|
||||
const createdAt = post.createdAt;
|
||||
return createdAt.getFullYear() === year
|
||||
&& createdAt.getMonth() === month - 1
|
||||
&& createdAt.getDate() === day;
|
||||
});
|
||||
|
||||
return {
|
||||
posts,
|
||||
totalPosts: result.totalPosts,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
|
||||
@@ -696,8 +835,38 @@ export class PreviewServer {
|
||||
filter: PostFilter,
|
||||
pagination?: { maxPostsPerPage: number; page?: number },
|
||||
): Promise<PostData[]> {
|
||||
const result = await this.loadPublishedSnapshotsPage(filter, pagination);
|
||||
return result.posts;
|
||||
}
|
||||
|
||||
private paginateSnapshots(
|
||||
snapshots: PostData[],
|
||||
pagination?: { maxPostsPerPage: number; page?: number },
|
||||
): { posts: PostData[]; totalPosts: number } {
|
||||
const totalPosts = snapshots.length;
|
||||
|
||||
if (typeof pagination?.maxPostsPerPage !== 'number') {
|
||||
return { posts: snapshots, totalPosts };
|
||||
}
|
||||
|
||||
const maxPostsPerPage = pagination.maxPostsPerPage;
|
||||
const page = Number.isInteger(pagination.page) && (pagination.page ?? 0) > 0
|
||||
? (pagination.page as number)
|
||||
: 1;
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
|
||||
return {
|
||||
posts: snapshots.slice(offset, offset + maxPostsPerPage),
|
||||
totalPosts,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPublishedSnapshotsPage(
|
||||
filter: PostFilter,
|
||||
pagination?: { maxPostsPerPage: number; page?: number },
|
||||
): Promise<{ posts: PostData[]; totalPosts: number }> {
|
||||
if (filter.status && filter.status !== 'published') {
|
||||
return [];
|
||||
return { posts: [], totalPosts: 0 };
|
||||
}
|
||||
|
||||
const baseFilter = this.buildSnapshotBaseFilter(filter);
|
||||
@@ -727,66 +896,150 @@ export class PreviewServer {
|
||||
|
||||
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
if (typeof pagination?.maxPostsPerPage === 'number') {
|
||||
const maxPostsPerPage = pagination.maxPostsPerPage;
|
||||
const page = Number.isInteger(pagination.page) && (pagination.page ?? 0) > 0
|
||||
? (pagination.page as number)
|
||||
: 1;
|
||||
const offset = (page - 1) * maxPostsPerPage;
|
||||
return snapshots.slice(offset, offset + maxPostsPerPage);
|
||||
return this.paginateSnapshots(snapshots, pagination);
|
||||
}
|
||||
|
||||
private async resolveRenderablePost(post: PostData): Promise<PostData> {
|
||||
if (post.status === 'published' && !post.content) {
|
||||
const fullPost = await this.postEngine.getPost(post.id);
|
||||
return fullPost ?? post;
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
return post;
|
||||
}
|
||||
|
||||
private buildListTemplateContext(
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
options: {
|
||||
archiveGrouping: boolean;
|
||||
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)
|
||||
: '';
|
||||
|
||||
return {
|
||||
page_title: options.page_title,
|
||||
language: options.language,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private async renderPostList(
|
||||
posts: PostData[],
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
options?: { archiveGrouping?: boolean },
|
||||
options: {
|
||||
archiveGrouping: boolean;
|
||||
basePathname: string;
|
||||
page_title: string;
|
||||
language: string;
|
||||
pagination?: PaginationContext;
|
||||
},
|
||||
): Promise<string> {
|
||||
const renderablePosts = await Promise.all(posts.map(async (post) => {
|
||||
if (post.status === 'published' && !post.content) {
|
||||
const fullPost = await this.postEngine.getPost(post.id);
|
||||
return fullPost ?? post;
|
||||
}
|
||||
return post;
|
||||
}));
|
||||
|
||||
if (!options?.archiveGrouping) {
|
||||
const rendered = await Promise.all(renderablePosts.map((post) => renderPostHtml(post, rewriteContext)));
|
||||
return rendered.join('\n');
|
||||
if (posts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const groups: Array<{ dateLabel: string; posts: PostData[] }> = [];
|
||||
let currentGroup: { key: string; dateLabel: string; posts: PostData[] } | null = null;
|
||||
const renderablePosts = await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post)));
|
||||
const templateContext = this.buildListTemplateContext(
|
||||
renderablePosts,
|
||||
rewriteContext,
|
||||
options,
|
||||
);
|
||||
|
||||
for (const post of renderablePosts) {
|
||||
const key = getArchiveDateKey(post.createdAt);
|
||||
if (!currentGroup || currentGroup.key !== key) {
|
||||
currentGroup = {
|
||||
key,
|
||||
dateLabel: formatArchiveDate(post.createdAt),
|
||||
posts: [],
|
||||
};
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
return this.liquid.renderFile('post-list', templateContext);
|
||||
}
|
||||
|
||||
currentGroup.posts.push(post);
|
||||
}
|
||||
private async renderSinglePost(
|
||||
post: PostData,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
pageContext: { page_title: string; language: string },
|
||||
): Promise<string> {
|
||||
const renderablePost = await this.resolveRenderablePost(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),
|
||||
};
|
||||
|
||||
const renderedGroups = await Promise.all(groups.map(async (group) => {
|
||||
const renderedPosts = await Promise.all(group.posts.map((post) => renderPostHtml(post, rewriteContext)));
|
||||
return `<section class="archive-day-group"><aside class="archive-day-marker"><span>${escapeHtml(group.dateLabel)}</span></aside><div class="archive-day-posts">${renderedPosts.join('\n')}</div></section>`;
|
||||
}));
|
||||
return this.liquid.renderFile('single-post', context);
|
||||
}
|
||||
|
||||
return renderedGroups
|
||||
.map((groupHtml, index) => {
|
||||
if (index === renderedGroups.length - 1) {
|
||||
return groupHtml;
|
||||
}
|
||||
return `${groupHtml}\n<div class="archive-day-separator" aria-hidden="true"></div>`;
|
||||
})
|
||||
.join('\n');
|
||||
private async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
||||
return this.liquid.renderFile('not-found', context);
|
||||
}
|
||||
|
||||
private async buildHtmlRewriteContext(): Promise<HtmlRewriteContext> {
|
||||
|
||||
15
src/main/engine/templates/not-found.liquid
Normal file
15
src/main/engine/templates/not-found.liquid
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}">
|
||||
{% render 'partials/head', page_title: page_title %}
|
||||
<body>
|
||||
<main>
|
||||
<section class="not-found" data-template="not-found">
|
||||
<article>
|
||||
<h1>404</h1>
|
||||
<p>The requested preview page could not be found.</p>
|
||||
<p><a href="/" role="button">Back to preview home</a></p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
9
src/main/engine/templates/partials/head.liquid
Normal file
9
src/main/engine/templates/partials/head.liquid
Normal file
@@ -0,0 +1,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title }}</title>
|
||||
<link rel="stylesheet" href="/assets/pico.min.css" />
|
||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||
{% render 'partials/styles' %}
|
||||
<script defer src="/assets/lightbox.min.js"></script>
|
||||
</head>
|
||||
22
src/main/engine/templates/partials/styles.liquid
Normal file
22
src/main/engine/templates/partials/styles.liquid
Normal file
@@ -0,0 +1,22 @@
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||
main { display: grid; gap: 1rem; }
|
||||
.post { border: 1px solid var(--muted-border-color); padding: 1rem; background: var(--card-background-color); }
|
||||
.post iframe { width: 100%; min-height: 20rem; }
|
||||
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; }
|
||||
.archive-day-group { display: grid; grid-template-columns: 5.25rem 1fr; gap: 1.25rem; align-items: stretch; }
|
||||
.archive-day-marker { display: flex; justify-content: center; align-items: center; color: var(--muted-color); }
|
||||
.archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; }
|
||||
.archive-day-posts { display: grid; gap: 1rem; }
|
||||
.archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--color); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; }
|
||||
.archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; }
|
||||
.single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; }
|
||||
.preview-pagination { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-top: .25rem; }
|
||||
.preview-pagination-link { color: var(--muted-color); text-decoration: none; font-size: .92rem; opacity: .72; transition: opacity .15s ease-in-out; }
|
||||
.preview-pagination-link:hover,
|
||||
.preview-pagination-link:focus-visible { opacity: 1; text-decoration: underline; }
|
||||
.preview-pagination .spacer { flex: 1; }
|
||||
.not-found { display: grid; place-items: center; min-height: 48vh; }
|
||||
.not-found article { max-width: 32rem; text-align: center; }
|
||||
</style>
|
||||
46
src/main/engine/templates/post-list.liquid
Normal file
46
src/main/engine/templates/post-list.liquid
Normal file
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}">
|
||||
{% render 'partials/head', page_title: page_title %}
|
||||
<body>
|
||||
<main>
|
||||
<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 %}
|
||||
<section class="archive-day-group">
|
||||
<aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside>
|
||||
<div class="archive-day-posts">
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if day_block.show_separator %}
|
||||
<div class="archive-day-separator" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if has_prev_page or has_next_page %}
|
||||
<nav class="preview-pagination" aria-label="Pagination">
|
||||
{% if has_prev_page %}
|
||||
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="Newer posts">neuer</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if has_next_page %}
|
||||
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="Older posts">älter</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
12
src/main/engine/templates/single-post.liquid
Normal file
12
src/main/engine/templates/single-post.liquid
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}">
|
||||
{% render 'partials/head', page_title: page_title %}
|
||||
<body>
|
||||
<main>
|
||||
<article class="single-post" data-template="single-post">
|
||||
<h1>{{ post.title }}</h1>
|
||||
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}</div>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user