feat: move to templates
This commit is contained in:
30
package-lock.json
generated
30
package-lock.json
generated
@@ -30,6 +30,7 @@
|
|||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lightbox2": "^2.11.5",
|
"lightbox2": "^2.11.5",
|
||||||
|
"liquidjs": "^10.24.0",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -9339,6 +9340,35 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lightbox2/-/lightbox2-2.11.5.tgz",
|
"resolved": "https://registry.npmjs.org/lightbox2/-/lightbox2-2.11.5.tgz",
|
||||||
"integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA=="
|
"integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/liquidjs": {
|
||||||
|
"version": "10.24.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.24.0.tgz",
|
||||||
|
"integrity": "sha512-TAUNAdgwaAXjjcUFuYVJm9kOVH7zc0mTKxsG9t9Lu4qdWjB2BEblyVIYpjWcmJLMGgiYqnGNJjpNMHx0gp/46A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^10.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"liquid": "bin/liquid.js",
|
||||||
|
"liquidjs": "bin/liquid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/liquidjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/liquidjs/node_modules/commander": {
|
||||||
|
"version": "10.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||||
|
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lightbox2": "^2.11.5",
|
"lightbox2": "^2.11.5",
|
||||||
|
"liquidjs": "^10.24.0",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import { Liquid } from 'liquidjs';
|
||||||
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
|
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
|
||||||
import { getMediaEngine, type MediaData } from './MediaEngine';
|
import { getMediaEngine, type MediaData } from './MediaEngine';
|
||||||
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
|
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
|
||||||
@@ -46,6 +47,53 @@ interface RoutePagination {
|
|||||||
page: number;
|
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 {
|
interface MediaEngineContract {
|
||||||
getAllMedia: () => Promise<MediaData[]>;
|
getAllMedia: () => Promise<MediaData[]>;
|
||||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||||
@@ -291,17 +339,6 @@ function renderMacro(name: string, params: Record<string, string>, postId: strin
|
|||||||
return '';
|
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 {
|
function buildCanonicalPostPath(post: PostData): string {
|
||||||
const year = post.createdAt.getFullYear();
|
const year = post.createdAt.getFullYear();
|
||||||
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
|
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -323,37 +360,28 @@ function getArchiveDateKey(date: Date): string {
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPageHtml(content: string, title: string, language: string): string {
|
function buildPaginationHref(basePathname: string, page: number): string {
|
||||||
return `<!doctype html>
|
const base = basePathname === '/' ? '' : basePathname;
|
||||||
<html lang="${escapeHtml(language)}">
|
if (page <= 1) {
|
||||||
<head>
|
return basePathname === '/' ? '/' : `${basePathname}/`;
|
||||||
<meta charset="utf-8" />
|
}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>${escapeHtml(title)}</title>
|
return `${base}/page/${page}/`;
|
||||||
<link rel="stylesheet" href="/assets/pico.min.css" />
|
}
|
||||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
|
||||||
<style>
|
function mapToRecord(map: Map<string, string>): Record<string, string> {
|
||||||
:root { color-scheme: light dark; }
|
return Object.fromEntries(map.entries());
|
||||||
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); }
|
function recordToMap(record: unknown): Map<string, string> {
|
||||||
.post iframe { width: 100%; min-height: 20rem; }
|
if (!record || typeof record !== 'object') {
|
||||||
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; }
|
return new Map<string, string>();
|
||||||
.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; }
|
return new Map<string, string>(
|
||||||
.archive-day-posts { display: grid; gap: 1rem; }
|
Object.entries(record as Record<string, unknown>)
|
||||||
.archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--color); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; }
|
.filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
|
||||||
.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>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PreviewServer {
|
export class PreviewServer {
|
||||||
@@ -361,6 +389,7 @@ export class PreviewServer {
|
|||||||
private readonly mediaEngine: MediaEngineContract;
|
private readonly mediaEngine: MediaEngineContract;
|
||||||
private readonly settingsEngine: MetaEngineContract;
|
private readonly settingsEngine: MetaEngineContract;
|
||||||
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
||||||
|
private readonly liquid: Liquid;
|
||||||
private server: Server | null = null;
|
private server: Server | null = null;
|
||||||
private port: number | null = null;
|
private port: number | null = null;
|
||||||
|
|
||||||
@@ -380,6 +409,34 @@ export class PreviewServer {
|
|||||||
projectDescription: activeProject?.description ?? undefined,
|
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> {
|
async start(preferredPort = 0): Promise<number> {
|
||||||
@@ -471,6 +528,8 @@ export class PreviewServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await this.settingsEngine.getProjectMetadata();
|
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 maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
||||||
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
||||||
|
|
||||||
@@ -495,21 +554,32 @@ export class PreviewServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext);
|
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, {
|
||||||
|
pageTitle,
|
||||||
|
language,
|
||||||
|
});
|
||||||
if (!result) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
this.respond(res, 200, result);
|
||||||
this.respond(res, 200, getPageHtml(result, resolvePageTitle(metadata, context.projectName, context.projectDescription), language));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PreviewServer] Request failed:', error);
|
console.error('[PreviewServer] Request failed:', error);
|
||||||
this.respond(res, 500, 'Internal Server 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);
|
const routePagination = parseRoutePagination(pathname);
|
||||||
if (!routePagination) {
|
if (!routePagination) {
|
||||||
return null;
|
return null;
|
||||||
@@ -522,61 +592,91 @@ export class PreviewServer {
|
|||||||
page,
|
page,
|
||||||
};
|
};
|
||||||
|
|
||||||
const postsYearMonthSlugMatch = pagedPathname.match(/^\/posts\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
const postsYearMonthSlugMatch = pagedPathname.match(/^\/posts\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
||||||
if (postsYearMonthSlugMatch) {
|
if (postsYearMonthSlugMatch) {
|
||||||
const year = Number(postsYearMonthSlugMatch[1]);
|
const year = Number(postsYearMonthSlugMatch[1]);
|
||||||
const month = Number(postsYearMonthSlugMatch[2]);
|
const month = Number(postsYearMonthSlugMatch[2]);
|
||||||
const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
const slug = postsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
||||||
if (month < 1 || month > 12) return null;
|
if (month < 1 || month > 12) return null;
|
||||||
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
||||||
if (!post) return null;
|
if (!post) return null;
|
||||||
return this.renderPostList([post], rewriteContext);
|
return this.renderSinglePost(post, rewriteContext, {
|
||||||
}
|
page_title: pageContext.pageTitle,
|
||||||
|
language: pageContext.language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/);
|
const postsSlugMatch = pagedPathname.match(/^\/posts\/([^/]+)$/);
|
||||||
if (postsSlugMatch) {
|
if (postsSlugMatch) {
|
||||||
const slug = postsSlugMatch[1].replace(/\.html?$/i, '');
|
const slug = postsSlugMatch[1].replace(/\.html?$/i, '');
|
||||||
const post = await this.findPublishedPostBySlug(slug);
|
const post = await this.findPublishedPostBySlug(slug);
|
||||||
if (!post) return null;
|
if (!post) return null;
|
||||||
return this.renderPostList([post], rewriteContext);
|
return this.renderSinglePost(post, rewriteContext, {
|
||||||
}
|
page_title: pageContext.pageTitle,
|
||||||
|
language: pageContext.language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const legacyPostsYearMonthSlugMatch = pagedPathname.match(/^\/post\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
const legacyPostsYearMonthSlugMatch = pagedPathname.match(/^\/post\/(\d{4})\/(\d{1,2})\/([^/]+)$/);
|
||||||
if (legacyPostsYearMonthSlugMatch) {
|
if (legacyPostsYearMonthSlugMatch) {
|
||||||
const year = Number(legacyPostsYearMonthSlugMatch[1]);
|
const year = Number(legacyPostsYearMonthSlugMatch[1]);
|
||||||
const month = Number(legacyPostsYearMonthSlugMatch[2]);
|
const month = Number(legacyPostsYearMonthSlugMatch[2]);
|
||||||
const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
const slug = legacyPostsYearMonthSlugMatch[3].replace(/\.html?$/i, '');
|
||||||
if (month < 1 || month > 12) return null;
|
if (month < 1 || month > 12) return null;
|
||||||
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
const post = await this.findPublishedPostBySlug(slug, { year, month: month - 1 });
|
||||||
if (!post) return null;
|
if (!post) return null;
|
||||||
return this.renderPostList([post], rewriteContext);
|
return this.renderSinglePost(post, rewriteContext, {
|
||||||
}
|
page_title: pageContext.pageTitle,
|
||||||
|
language: pageContext.language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/);
|
const legacyPostsSlugMatch = pagedPathname.match(/^\/post\/([^/]+)$/);
|
||||||
if (legacyPostsSlugMatch) {
|
if (legacyPostsSlugMatch) {
|
||||||
const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, '');
|
const slug = legacyPostsSlugMatch[1].replace(/\.html?$/i, '');
|
||||||
const post = await this.findPublishedPostBySlug(slug);
|
const post = await this.findPublishedPostBySlug(slug);
|
||||||
if (!post) return null;
|
if (!post) return null;
|
||||||
return this.renderPostList([post], rewriteContext);
|
return this.renderSinglePost(post, rewriteContext, {
|
||||||
}
|
page_title: pageContext.pageTitle,
|
||||||
|
language: pageContext.language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (pagedPathname === '/') {
|
if (pagedPathname === '/') {
|
||||||
const posts = await this.loadPublishedSnapshots({ status: 'published' }, pageOptions);
|
const result = await this.loadPublishedSnapshotsPage({ status: 'published' }, pageOptions);
|
||||||
return this.renderPostList(posts, rewriteContext);
|
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\/([^/]+)$/);
|
const tagMatch = pagedPathname.match(/^\/tag\/([^/]+)$/);
|
||||||
if (tagMatch) {
|
if (tagMatch) {
|
||||||
const tag = tagMatch[1];
|
const tag = tagMatch[1];
|
||||||
const posts = await this.loadPublishedSnapshots({ status: 'published', tags: [tag] }, pageOptions);
|
const result = await this.loadPublishedSnapshotsPage({ status: 'published', tags: [tag] }, pageOptions);
|
||||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
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\/([^/]+)$/);
|
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
|
||||||
if (categoryMatch) {
|
if (categoryMatch) {
|
||||||
const category = categoryMatch[1];
|
const category = categoryMatch[1];
|
||||||
const posts = await this.loadPublishedSnapshots({ status: 'published', categories: [category] }, pageOptions);
|
const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category] }, pageOptions);
|
||||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
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})\/([^/]+)$/);
|
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 posts = await this.loadPostsForDay(year, month, day);
|
||||||
const post = posts.find((candidate) => candidate.slug === slug) || null;
|
const post = posts.find((candidate) => candidate.slug === slug) || null;
|
||||||
if (!post) return 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})$/);
|
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 year = Number(dayMatch[1]);
|
||||||
const month = Number(dayMatch[2]);
|
const month = Number(dayMatch[2]);
|
||||||
const day = Number(dayMatch[3]);
|
const day = Number(dayMatch[3]);
|
||||||
const posts = await this.loadPostsForDay(year, month, day, pageOptions);
|
const result = await this.loadPostsForDayPage(year, month, day, pageOptions);
|
||||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
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})$/);
|
const monthMatch = pagedPathname.match(/^\/(\d{4})\/(\d{1,2})$/);
|
||||||
@@ -605,15 +714,27 @@ export class PreviewServer {
|
|||||||
const year = Number(monthMatch[1]);
|
const year = Number(monthMatch[1]);
|
||||||
const month = Number(monthMatch[2]);
|
const month = Number(monthMatch[2]);
|
||||||
if (month < 1 || month > 12) return null;
|
if (month < 1 || month > 12) return null;
|
||||||
const posts = await this.loadPublishedSnapshots({ status: 'published', year, month: month - 1 }, pageOptions);
|
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1 }, pageOptions);
|
||||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
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})$/);
|
const yearMatch = pagedPathname.match(/^\/(\d{4})$/);
|
||||||
if (yearMatch) {
|
if (yearMatch) {
|
||||||
const year = Number(yearMatch[1]);
|
const year = Number(yearMatch[1]);
|
||||||
const posts = await this.loadPublishedSnapshots({ status: 'published', year }, pageOptions);
|
const result = await this.loadPublishedSnapshotsPage({ status: 'published', year }, pageOptions);
|
||||||
return this.renderPostList(posts, rewriteContext, { archiveGrouping: true });
|
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(/^\/([^/]+)$/);
|
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
|
||||||
@@ -622,7 +743,10 @@ export class PreviewServer {
|
|||||||
const pages = await this.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
|
const pages = await this.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
|
||||||
const page = pages.find((candidate) => candidate.slug === slug) || null;
|
const page = pages.find((candidate) => candidate.slug === slug) || null;
|
||||||
if (!page) return null;
|
if (!page) return null;
|
||||||
return this.renderPostList([page], rewriteContext);
|
return this.renderSinglePost(page, rewriteContext, {
|
||||||
|
page_title: pageContext.pageTitle,
|
||||||
|
language: pageContext.language,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -648,25 +772,40 @@ export class PreviewServer {
|
|||||||
day: number,
|
day: number,
|
||||||
pagination?: { maxPostsPerPage: number; page?: number },
|
pagination?: { maxPostsPerPage: number; page?: number },
|
||||||
): Promise<PostData[]> {
|
): 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) {
|
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 startDate = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
|
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||||
|
|
||||||
const posts = await this.loadPublishedSnapshots({
|
const result = await this.loadPublishedSnapshotsPage({
|
||||||
status: 'published',
|
status: 'published',
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
}, pagination);
|
}, pagination);
|
||||||
|
|
||||||
return posts.filter((post) => {
|
const posts = result.posts.filter((post) => {
|
||||||
const createdAt = post.createdAt;
|
const createdAt = post.createdAt;
|
||||||
return createdAt.getFullYear() === year
|
return createdAt.getFullYear() === year
|
||||||
&& createdAt.getMonth() === month - 1
|
&& createdAt.getMonth() === month - 1
|
||||||
&& createdAt.getDate() === day;
|
&& createdAt.getDate() === day;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
totalPosts: result.totalPosts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
|
private buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
|
||||||
@@ -696,8 +835,38 @@ export class PreviewServer {
|
|||||||
filter: PostFilter,
|
filter: PostFilter,
|
||||||
pagination?: { maxPostsPerPage: number; page?: number },
|
pagination?: { maxPostsPerPage: number; page?: number },
|
||||||
): Promise<PostData[]> {
|
): 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') {
|
if (filter.status && filter.status !== 'published') {
|
||||||
return [];
|
return { posts: [], totalPosts: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseFilter = this.buildSnapshotBaseFilter(filter);
|
const baseFilter = this.buildSnapshotBaseFilter(filter);
|
||||||
@@ -727,66 +896,150 @@ export class PreviewServer {
|
|||||||
|
|
||||||
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
|
||||||
if (typeof pagination?.maxPostsPerPage === 'number') {
|
return this.paginateSnapshots(snapshots, pagination);
|
||||||
const maxPostsPerPage = pagination.maxPostsPerPage;
|
}
|
||||||
const page = Number.isInteger(pagination.page) && (pagination.page ?? 0) > 0
|
|
||||||
? (pagination.page as number)
|
private async resolveRenderablePost(post: PostData): Promise<PostData> {
|
||||||
: 1;
|
if (post.status === 'published' && !post.content) {
|
||||||
const offset = (page - 1) * maxPostsPerPage;
|
const fullPost = await this.postEngine.getPost(post.id);
|
||||||
return snapshots.slice(offset, offset + maxPostsPerPage);
|
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(
|
private async renderPostList(
|
||||||
posts: PostData[],
|
posts: PostData[],
|
||||||
rewriteContext: HtmlRewriteContext,
|
rewriteContext: HtmlRewriteContext,
|
||||||
options?: { archiveGrouping?: boolean },
|
options: {
|
||||||
|
archiveGrouping: boolean;
|
||||||
|
basePathname: string;
|
||||||
|
page_title: string;
|
||||||
|
language: string;
|
||||||
|
pagination?: PaginationContext;
|
||||||
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const renderablePosts = await Promise.all(posts.map(async (post) => {
|
if (posts.length === 0) {
|
||||||
if (post.status === 'published' && !post.content) {
|
return '';
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups: Array<{ dateLabel: string; posts: PostData[] }> = [];
|
const renderablePosts = await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post)));
|
||||||
let currentGroup: { key: string; dateLabel: string; posts: PostData[] } | null = null;
|
const templateContext = this.buildListTemplateContext(
|
||||||
|
renderablePosts,
|
||||||
|
rewriteContext,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
for (const post of renderablePosts) {
|
return this.liquid.renderFile('post-list', templateContext);
|
||||||
const key = getArchiveDateKey(post.createdAt);
|
}
|
||||||
if (!currentGroup || currentGroup.key !== key) {
|
|
||||||
currentGroup = {
|
|
||||||
key,
|
|
||||||
dateLabel: formatArchiveDate(post.createdAt),
|
|
||||||
posts: [],
|
|
||||||
};
|
|
||||||
groups.push(currentGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
return this.liquid.renderFile('single-post', context);
|
||||||
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 renderedGroups
|
private async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
||||||
.map((groupHtml, index) => {
|
return this.liquid.renderFile('not-found', context);
|
||||||
if (index === renderedGroups.length - 1) {
|
|
||||||
return groupHtml;
|
|
||||||
}
|
|
||||||
return `${groupHtml}\n<div class="archive-day-separator" aria-hidden="true"></div>`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildHtmlRewriteContext(): Promise<HtmlRewriteContext> {
|
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>
|
||||||
@@ -289,6 +289,31 @@ describe('PreviewServer', () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
expect(html).toContain('Single Post');
|
expect(html).toContain('Single Post');
|
||||||
|
expect(html).toContain('data-template="single-post"');
|
||||||
|
expect(html).toContain('.single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders single post title as h1', async () => {
|
||||||
|
const post = makePost({
|
||||||
|
id: 'single-title',
|
||||||
|
title: 'Explicit Single Post Title',
|
||||||
|
slug: 'single-title',
|
||||||
|
createdAt: new Date('2025-02-14T10:00:00.000Z'),
|
||||||
|
content: 'Plain body without markdown heading',
|
||||||
|
});
|
||||||
|
|
||||||
|
server = new PreviewServer({
|
||||||
|
postEngine: makeEngine([post]),
|
||||||
|
settingsEngine: makeSettings(50),
|
||||||
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start(0);
|
||||||
|
|
||||||
|
const response = await fetch(`${server.getBaseUrl()}/2025/2/14/single-title/`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const html = await response.text();
|
||||||
|
expect(html).toContain('<h1>Explicit Single Post Title</h1>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports tag, category, and page-slug routes', async () => {
|
it('supports tag, category, and page-slug routes', async () => {
|
||||||
@@ -387,12 +412,22 @@ describe('PreviewServer', () => {
|
|||||||
await server.start(0);
|
await server.start(0);
|
||||||
|
|
||||||
const rootPageTwoHtml = await (await fetch(`${server.getBaseUrl()}/page/2/`)).text();
|
const rootPageTwoHtml = await (await fetch(`${server.getBaseUrl()}/page/2/`)).text();
|
||||||
|
expect(rootPageTwoHtml).toContain('data-template="post-list"');
|
||||||
|
expect(rootPageTwoHtml).toContain('data-first-page="false"');
|
||||||
|
expect(rootPageTwoHtml).toContain('data-last-page="false"');
|
||||||
|
expect(rootPageTwoHtml).toContain('href="/"');
|
||||||
|
expect(rootPageTwoHtml).toContain('href="/page/3/"');
|
||||||
|
expect(rootPageTwoHtml).toContain('class="preview-pagination-link"');
|
||||||
|
expect(rootPageTwoHtml).not.toContain('role="button"');
|
||||||
expect(rootPageTwoHtml).toContain('History 51');
|
expect(rootPageTwoHtml).toContain('History 51');
|
||||||
expect(rootPageTwoHtml).toContain('History 100');
|
expect(rootPageTwoHtml).toContain('History 100');
|
||||||
expect(rootPageTwoHtml).not.toContain('History 50');
|
expect(rootPageTwoHtml).not.toContain('History 50');
|
||||||
expect(rootPageTwoHtml).not.toContain('History 101');
|
expect(rootPageTwoHtml).not.toContain('History 101');
|
||||||
|
|
||||||
const yearPageThreeHtml = await (await fetch(`${server.getBaseUrl()}/2020/page/3/`)).text();
|
const yearPageThreeHtml = await (await fetch(`${server.getBaseUrl()}/2020/page/3/`)).text();
|
||||||
|
expect(yearPageThreeHtml).toContain('data-last-page="true"');
|
||||||
|
expect(yearPageThreeHtml).toContain('href="/2020/page/2/"');
|
||||||
|
expect(yearPageThreeHtml).not.toContain('href="/2020/page/4/"');
|
||||||
expect(yearPageThreeHtml).toContain('History 101');
|
expect(yearPageThreeHtml).toContain('History 101');
|
||||||
expect(yearPageThreeHtml).toContain('History 120');
|
expect(yearPageThreeHtml).toContain('History 120');
|
||||||
expect(yearPageThreeHtml).not.toContain('History 100');
|
expect(yearPageThreeHtml).not.toContain('History 100');
|
||||||
@@ -768,4 +803,20 @@ describe('PreviewServer', () => {
|
|||||||
|
|
||||||
expect(getPost).toHaveBeenCalledTimes(50);
|
expect(getPost).toHaveBeenCalledTimes(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders custom 404 template for unknown routes', async () => {
|
||||||
|
server = new PreviewServer({
|
||||||
|
postEngine: makeEngine([makePost()]),
|
||||||
|
settingsEngine: makeSettings(50),
|
||||||
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start(0);
|
||||||
|
|
||||||
|
const response = await fetch(`${server.getBaseUrl()}/does-not-exist/`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
const html = await response.text();
|
||||||
|
expect(html).toContain('data-template="not-found"');
|
||||||
|
expect(html).toContain('class="not-found"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user