668 lines
24 KiB
TypeScript
668 lines
24 KiB
TypeScript
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
|
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { type CategoryMetadata, type ProjectMetadata } from './MetaEngine';
|
|
import { type MediaData } from './MediaEngine';
|
|
import { type MenuDocument } from './MenuEngine';
|
|
import { type PostData, type PostFilter } from './PostEngine';
|
|
import {
|
|
PageRenderer,
|
|
PREVIEW_ASSETS,
|
|
PREVIEW_IMAGE_ASSETS,
|
|
buildTemplateMenuItems,
|
|
buildCanonicalPostPath,
|
|
clampMaxPostsPerPage,
|
|
resolvePageTitle,
|
|
type CategoryRenderSettings,
|
|
type HtmlRewriteContext,
|
|
type MediaEngineContract,
|
|
type PostMediaEngineContract,
|
|
type PythonMacroRendererContract,
|
|
} from './PageRenderer';
|
|
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
|
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
|
import {
|
|
findSinglePostBySlug,
|
|
loadPostsForDayPage,
|
|
loadPublishedSnapshots,
|
|
loadPublishedSnapshotsPage,
|
|
} from './SharedSnapshotService';
|
|
import { buildCalendarArchiveData } from './GenerationSitemapFeedService';
|
|
import { loadPublishedGenerationSets } from './GenerationPostSnapshotService';
|
|
|
|
interface ActiveProjectContext {
|
|
projectId: string;
|
|
dataDir?: string;
|
|
projectName?: string;
|
|
projectDescription?: string;
|
|
}
|
|
|
|
interface PostEngineContract {
|
|
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
|
getPost: (id: string) => Promise<PostData | null>;
|
|
hasPublishedVersion: (id: string) => Promise<boolean>;
|
|
getPublishedVersion: (id: string) => Promise<PostData | null>;
|
|
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
|
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
|
}
|
|
|
|
interface MetaEngineContract {
|
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
|
isInitialized?: () => boolean;
|
|
syncOnStartup?: () => Promise<void>;
|
|
}
|
|
|
|
interface MenuEngineContract {
|
|
getMenu: () => Promise<MenuDocument>;
|
|
setProjectContext: (projectId: string, dataDir?: string) => void;
|
|
}
|
|
|
|
interface PreviewServerDependencies {
|
|
postEngine: PostEngineContract;
|
|
mediaEngine: MediaEngineContract;
|
|
postMediaEngine: PostMediaEngineContract;
|
|
settingsEngine: MetaEngineContract;
|
|
menuEngine: MenuEngineContract;
|
|
getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
|
userTemplatesDir?: string;
|
|
macroRenderer?: PythonMacroRendererContract;
|
|
}
|
|
|
|
interface SerializedTag {
|
|
name?: unknown;
|
|
color?: unknown;
|
|
}
|
|
|
|
export class PreviewServer {
|
|
private readonly postEngine: PostEngineContract;
|
|
private readonly mediaEngine: MediaEngineContract;
|
|
private readonly postMediaEngine: PostMediaEngineContract;
|
|
private readonly settingsEngine: MetaEngineContract;
|
|
private readonly menuEngine: MenuEngineContract;
|
|
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
|
private readonly pageRenderer: PageRenderer;
|
|
private readonly tagColorByNameCache = new Map<string, Promise<Record<string, string>>>();
|
|
private server: Server | null = null;
|
|
private port: number | null = null;
|
|
|
|
constructor(dependencies?: Partial<PreviewServerDependencies>) {
|
|
if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided');
|
|
if (!dependencies?.mediaEngine) throw new Error('PreviewServer: mediaEngine not provided');
|
|
if (!dependencies?.postMediaEngine) throw new Error('PreviewServer: postMediaEngine not provided');
|
|
if (!dependencies?.settingsEngine) throw new Error('PreviewServer: settingsEngine not provided');
|
|
if (!dependencies?.menuEngine) throw new Error('PreviewServer: menuEngine not provided');
|
|
if (!dependencies?.getActiveProjectContext) throw new Error('PreviewServer: getActiveProjectContext not provided');
|
|
this.postEngine = dependencies.postEngine;
|
|
this.mediaEngine = dependencies.mediaEngine;
|
|
this.postMediaEngine = dependencies.postMediaEngine;
|
|
this.settingsEngine = dependencies.settingsEngine;
|
|
this.menuEngine = dependencies.menuEngine;
|
|
this.getActiveProjectContext = dependencies.getActiveProjectContext;
|
|
this.pageRenderer = new PageRenderer(
|
|
this.mediaEngine,
|
|
this.postMediaEngine,
|
|
this.postEngine,
|
|
dependencies.macroRenderer ?? buildNoopMacroRenderer(),
|
|
dependencies.userTemplatesDir,
|
|
);
|
|
}
|
|
|
|
async start(preferredPort = 0): Promise<number> {
|
|
if (this.server && this.port !== null) {
|
|
return this.port;
|
|
}
|
|
|
|
this.server = createServer(async (req, res) => {
|
|
await this.handleRequest(req, res);
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (!this.server) {
|
|
reject(new Error('Preview server was not created'));
|
|
return;
|
|
}
|
|
|
|
this.server.once('error', reject);
|
|
this.server.listen(preferredPort, '127.0.0.1', () => {
|
|
this.server?.off('error', reject);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const address = this.server.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Failed to get preview server address');
|
|
}
|
|
|
|
this.port = address.port;
|
|
return this.port;
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
if (!this.server) {
|
|
this.port = null;
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (!this.server) {
|
|
resolve();
|
|
return;
|
|
}
|
|
this.server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
this.server = null;
|
|
this.port = null;
|
|
}
|
|
|
|
getBaseUrl(): string {
|
|
if (this.port === null) {
|
|
throw new Error('Preview server not started');
|
|
}
|
|
return `http://127.0.0.1:${this.port}`;
|
|
}
|
|
|
|
async renderRouteForContext(
|
|
pathname: string,
|
|
options: {
|
|
projectContext: ActiveProjectContext;
|
|
metadata?: ProjectMetadata | null;
|
|
menu?: MenuDocument;
|
|
htmlRewriteContext?: HtmlRewriteContext;
|
|
skipContextSetup?: boolean;
|
|
maxPostsPerPage?: number;
|
|
requestTheme?: string | null;
|
|
htmlThemeAttribute?: string;
|
|
allowEmptyArchiveRender?: boolean;
|
|
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
|
|
},
|
|
): Promise<string | null> {
|
|
return renderRouteWithSharedContext(pathname, options, {
|
|
postEngine: this.postEngine,
|
|
mediaEngine: this.mediaEngine,
|
|
postMediaEngine: this.postMediaEngine,
|
|
settingsEngine: this.settingsEngine,
|
|
menuEngine: this.menuEngine,
|
|
resolveCategoryMetadata: (metadata) => this.resolveCategoryMetadata(metadata),
|
|
resolveCategorySettings: (metadata) => this.resolveCategorySettings(metadata),
|
|
resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings),
|
|
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
|
|
resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext),
|
|
resolveTagTemplateSettings: (projectContext) => this.resolveTagTemplateSettings(projectContext),
|
|
pageRenderer: this.pageRenderer,
|
|
postEngineForMacros: this.postEngine,
|
|
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
|
|
loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
|
|
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
|
|
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter),
|
|
getLinkedBy: this.postEngine.getLinkedBy ? (postId) => this.postEngine.getLinkedBy!(postId) : undefined,
|
|
});
|
|
}
|
|
|
|
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const remoteAddress = req.socket.remoteAddress;
|
|
const isLocal = remoteAddress === '127.0.0.1'
|
|
|| remoteAddress === '::1'
|
|
|| remoteAddress === '::ffff:127.0.0.1';
|
|
|
|
if (!isLocal) {
|
|
this.respond(res, 403, 'Forbidden');
|
|
return;
|
|
}
|
|
|
|
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
this.respond(res, 405, 'Method Not Allowed');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
|
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
|
|
|
const asset = await this.resolveAsset(pathname);
|
|
if (asset) {
|
|
this.respondAsset(res, asset.contentType, asset.body);
|
|
return;
|
|
}
|
|
|
|
const context = await this.getActiveProjectContext();
|
|
this.postEngine.setProjectContext(context.projectId, context.dataDir);
|
|
this.mediaEngine.setProjectContext?.(context.projectId, context.dataDir, context.dataDir);
|
|
this.postMediaEngine.setProjectContext(context.projectId);
|
|
this.settingsEngine.setProjectContext(context.projectId, context.dataDir);
|
|
this.menuEngine.setProjectContext(context.projectId, context.dataDir);
|
|
|
|
if (this.settingsEngine.isInitialized && this.settingsEngine.syncOnStartup && !this.settingsEngine.isInitialized()) {
|
|
await this.settingsEngine.syncOnStartup();
|
|
}
|
|
|
|
const metadata = await this.settingsEngine.getProjectMetadata();
|
|
const categoryMetadata = this.resolveCategoryMetadata(metadata);
|
|
const menu = await this.menuEngine.getMenu().catch(() => ({ items: [] }));
|
|
const menuItems = buildTemplateMenuItems(menu, categoryMetadata);
|
|
const categorySettings = this.resolveCategorySettings(metadata);
|
|
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
|
|
const language = metadata?.mainLanguage?.trim() || 'en';
|
|
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
|
|
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
|
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
|
|
const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode'));
|
|
const useDraftContent = requestUrl.searchParams.get('draft') === 'true';
|
|
const draftPostId = requestUrl.searchParams.get('postId') || undefined;
|
|
const appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
|
|
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
|
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
|
|
|
if (pathname === '/calendar.json') {
|
|
const calendarJson = await this.resolveCalendarJson(context.dataDir, listExcludedCategories);
|
|
this.respondAsset(res, 'application/json; charset=utf-8', calendarJson);
|
|
return;
|
|
}
|
|
|
|
if (pathname === '/__style-preview') {
|
|
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
|
|
pageTitle,
|
|
language,
|
|
menuItems,
|
|
picoStylesheetHref,
|
|
htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined,
|
|
}, categorySettings, listExcludedCategories);
|
|
this.respond(res, 200, stylePreviewHtml);
|
|
return;
|
|
}
|
|
|
|
const imageAsset = await this.resolveImageAsset(pathname);
|
|
if (imageAsset) {
|
|
this.respondAsset(res, imageAsset.contentType, imageAsset.body);
|
|
return;
|
|
}
|
|
|
|
const mediaAsset = await this.resolveMediaAsset(pathname, context.dataDir);
|
|
if (mediaAsset) {
|
|
this.respondAsset(res, mediaAsset.contentType, mediaAsset.body);
|
|
return;
|
|
}
|
|
|
|
const result = await this.renderRouteForContext(pathname, {
|
|
projectContext: context,
|
|
metadata,
|
|
menu,
|
|
maxPostsPerPage,
|
|
requestTheme,
|
|
htmlThemeAttribute: undefined,
|
|
singlePostOptions: {
|
|
useDraftContent,
|
|
draftPostId,
|
|
},
|
|
});
|
|
if (!result) {
|
|
const notFoundHtml = await this.pageRenderer.renderNotFound({
|
|
page_title: '404 Not Found',
|
|
language,
|
|
menu_items: menuItems,
|
|
pico_stylesheet_href: picoStylesheetHref,
|
|
html_theme_attribute: undefined,
|
|
});
|
|
this.respond(res, 404, notFoundHtml);
|
|
return;
|
|
}
|
|
|
|
this.respond(res, 200, result);
|
|
} catch (error) {
|
|
console.error('[PreviewServer] Request failed:', error);
|
|
this.respond(res, 500, 'Internal Server Error');
|
|
}
|
|
}
|
|
|
|
private async renderStylePreview(
|
|
rewriteContext: HtmlRewriteContext,
|
|
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
|
categorySettings: Record<string, CategoryRenderSettings>,
|
|
listExcludedCategories: string[],
|
|
): Promise<string> {
|
|
const result = await loadPublishedSnapshotsPage(this.postEngine, { status: 'published', excludeCategories: listExcludedCategories }, {
|
|
maxPostsPerPage: 10,
|
|
page: 1,
|
|
});
|
|
|
|
if (result.posts.length === 0) {
|
|
return this.pageRenderer.renderNotFound({
|
|
page_title: pageContext.pageTitle,
|
|
language: pageContext.language,
|
|
menu_items: pageContext.menuItems,
|
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
|
});
|
|
}
|
|
|
|
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
|
archiveGrouping: true,
|
|
routeKind: 'date',
|
|
archiveContext: { kind: 'root' },
|
|
basePathname: '/__style-preview',
|
|
pagination: { page: 1, maxPostsPerPage: 10, 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,
|
|
}, this.postEngine);
|
|
}
|
|
|
|
private async buildHtmlRewriteContext(): Promise<HtmlRewriteContext> {
|
|
const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' });
|
|
const canonicalPostPathBySlug = new Map<string, string>();
|
|
|
|
for (const post of publishedPosts) {
|
|
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
|
|
}
|
|
|
|
const canonicalMediaPathBySourcePath = new Map<string, string>();
|
|
try {
|
|
const mediaItems = await this.mediaEngine.getAllMedia();
|
|
for (const media of mediaItems) {
|
|
const year = media.createdAt.getFullYear();
|
|
const month = String(media.createdAt.getMonth() + 1).padStart(2, '0');
|
|
const canonicalPath = `/media/${year}/${month}/${media.filename}`;
|
|
|
|
const originalNameKey = `media/${year}/${month}/${media.originalName}`.toLowerCase();
|
|
const filenameKey = `media/${year}/${month}/${media.filename}`.toLowerCase();
|
|
|
|
canonicalMediaPathBySourcePath.set(originalNameKey, canonicalPath);
|
|
canonicalMediaPathBySourcePath.set(filenameKey, canonicalPath);
|
|
}
|
|
} catch {
|
|
// Keep media map empty if media metadata cannot be loaded.
|
|
}
|
|
|
|
return {
|
|
canonicalPostPathBySlug,
|
|
canonicalMediaPathBySourcePath,
|
|
};
|
|
}
|
|
|
|
private async resolveTagColorByName(projectContext: ActiveProjectContext): Promise<Record<string, string>> {
|
|
const cacheKey = `${projectContext.projectId}:${projectContext.dataDir ?? ''}`;
|
|
const cached = this.tagColorByNameCache.get(cacheKey);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const promise = this.loadTagColorByName(projectContext.dataDir);
|
|
this.tagColorByNameCache.set(cacheKey, promise);
|
|
return promise;
|
|
}
|
|
|
|
private async loadTagColorByName(dataDir?: string): Promise<Record<string, string>> {
|
|
if (!dataDir) {
|
|
return {};
|
|
}
|
|
|
|
const tagsPath = path.join(dataDir, 'meta', 'tags.json');
|
|
|
|
try {
|
|
const source = await readFile(tagsPath, 'utf-8');
|
|
const parsed = JSON.parse(source);
|
|
if (!Array.isArray(parsed)) {
|
|
return {};
|
|
}
|
|
|
|
const colors: Record<string, string> = {};
|
|
for (const rawEntry of parsed as SerializedTag[]) {
|
|
const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : '';
|
|
const color = typeof rawEntry?.color === 'string' ? rawEntry.color.trim() : '';
|
|
if (!name || !color) {
|
|
continue;
|
|
}
|
|
|
|
colors[name] = color;
|
|
}
|
|
|
|
return colors;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
private async resolveTagTemplateSettings(projectContext: ActiveProjectContext): Promise<Record<string, { postTemplateSlug?: string | null }>> {
|
|
if (!projectContext.dataDir) {
|
|
return {};
|
|
}
|
|
|
|
const tagsPath = path.join(projectContext.dataDir, 'meta', 'tags.json');
|
|
|
|
try {
|
|
const source = await readFile(tagsPath, 'utf-8');
|
|
const parsed = JSON.parse(source);
|
|
if (!Array.isArray(parsed)) {
|
|
return {};
|
|
}
|
|
|
|
const settings: Record<string, { postTemplateSlug?: string | null }> = {};
|
|
for (const rawEntry of parsed as SerializedTag[]) {
|
|
const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : '';
|
|
const postTemplateSlug = typeof (rawEntry as Record<string, unknown>)?.postTemplateSlug === 'string'
|
|
? ((rawEntry as Record<string, unknown>).postTemplateSlug as string).trim()
|
|
: undefined;
|
|
if (!name || !postTemplateSlug) {
|
|
continue;
|
|
}
|
|
|
|
settings[name] = { postTemplateSlug };
|
|
}
|
|
|
|
return settings;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
|
|
const match = pathname.match(/^\/assets\/([^/]+)$/);
|
|
if (!match) return null;
|
|
|
|
const assetName = match[1];
|
|
const assetDefinition = PREVIEW_ASSETS[assetName];
|
|
if (!assetDefinition) return null;
|
|
|
|
try {
|
|
const body = assetDefinition.sourceText !== undefined
|
|
? Buffer.from(assetDefinition.sourceText, 'utf-8')
|
|
: await readFile(require.resolve(assetDefinition.modulePath as string));
|
|
return {
|
|
contentType: assetDefinition.contentType,
|
|
body,
|
|
};
|
|
} catch (error) {
|
|
console.error(`[PreviewServer] Failed to read local asset: ${assetDefinition.modulePath ?? assetName}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async resolveImageAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
|
|
const match = pathname.match(/^\/images\/([^/]+)$/);
|
|
if (!match) return null;
|
|
|
|
const assetName = match[1] as keyof typeof PREVIEW_IMAGE_ASSETS;
|
|
const assetDefinition = PREVIEW_IMAGE_ASSETS[assetName];
|
|
if (!assetDefinition) return null;
|
|
|
|
try {
|
|
const absolutePath = require.resolve(assetDefinition.modulePath);
|
|
const body = await readFile(absolutePath);
|
|
return {
|
|
contentType: assetDefinition.contentType,
|
|
body,
|
|
};
|
|
} catch (error) {
|
|
console.error(`[PreviewServer] Failed to read image asset: ${assetDefinition.modulePath}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> {
|
|
const match = pathname.match(/^\/media\/(.+)$/);
|
|
if (!match || !dataDir) return null;
|
|
|
|
const relativeMediaPath = path.posix.normalize(`media/${match[1]}`);
|
|
if (!relativeMediaPath.startsWith('media/')) {
|
|
return null;
|
|
}
|
|
|
|
const absoluteDataDir = path.resolve(dataDir);
|
|
const mediaRoot = path.resolve(absoluteDataDir, 'media');
|
|
const absoluteMediaPath = path.resolve(absoluteDataDir, relativeMediaPath);
|
|
|
|
if (absoluteMediaPath !== mediaRoot && !absoluteMediaPath.startsWith(`${mediaRoot}${path.sep}`)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const body = await readFile(absoluteMediaPath);
|
|
return {
|
|
contentType: this.getMediaContentType(absoluteMediaPath),
|
|
body,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async resolveCalendarJson(dataDir: string | undefined, listExcludedCategories: string[]): Promise<Buffer> {
|
|
if (dataDir) {
|
|
const calendarPath = path.join(dataDir, 'html', 'calendar.json');
|
|
try {
|
|
return await readFile(calendarPath);
|
|
} catch {
|
|
// fall through to dynamic generation for preview runtime
|
|
}
|
|
}
|
|
|
|
const { publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
|
|
const calendarJson = `${JSON.stringify(buildCalendarArchiveData(publishedListPosts), null, 2)}\n`;
|
|
return Buffer.from(calendarJson, 'utf-8');
|
|
}
|
|
|
|
private getMediaContentType(filePath: string): string {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
switch (extension) {
|
|
case '.jpg':
|
|
case '.jpeg':
|
|
return 'image/jpeg';
|
|
case '.png':
|
|
return 'image/png';
|
|
case '.gif':
|
|
return 'image/gif';
|
|
case '.webp':
|
|
return 'image/webp';
|
|
case '.svg':
|
|
return 'image/svg+xml';
|
|
case '.bmp':
|
|
return 'image/bmp';
|
|
case '.avif':
|
|
return 'image/avif';
|
|
case '.mp4':
|
|
return 'video/mp4';
|
|
case '.webm':
|
|
return 'video/webm';
|
|
case '.mov':
|
|
return 'video/quicktime';
|
|
case '.pdf':
|
|
return 'application/pdf';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
private resolveCategoryMetadata(metadata: ProjectMetadata | null): Record<string, CategoryMetadata> {
|
|
const defaults: Record<string, CategoryMetadata> = {
|
|
article: { renderInLists: true, showTitle: true, title: 'article' },
|
|
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
|
};
|
|
|
|
const rawMetadata = (metadata as { categoryMetadata?: unknown } | null)?.categoryMetadata;
|
|
const rawLegacySettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings;
|
|
const source = rawMetadata && typeof rawMetadata === 'object' ? rawMetadata : rawLegacySettings;
|
|
|
|
if (!source || typeof source !== 'object') {
|
|
return defaults;
|
|
}
|
|
|
|
const merged: Record<string, CategoryMetadata> = { ...defaults };
|
|
for (const [category, rawValue] of Object.entries(source as Record<string, unknown>)) {
|
|
if (!rawValue || typeof rawValue !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
const typedRawValue = rawValue as Record<string, unknown>;
|
|
const title = typeof typedRawValue.title === 'string' && typedRawValue.title.trim().length > 0
|
|
? typedRawValue.title.trim()
|
|
: category;
|
|
merged[category] = {
|
|
renderInLists: typedRawValue.renderInLists !== false,
|
|
showTitle: typedRawValue.showTitle !== false,
|
|
title,
|
|
};
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
private resolveCategorySettings(metadata: ProjectMetadata | null): Record<string, CategoryRenderSettings> {
|
|
const categoryMetadata = this.resolveCategoryMetadata(metadata);
|
|
const mergedSettings: Record<string, CategoryRenderSettings> = {};
|
|
for (const [category, value] of Object.entries(categoryMetadata)) {
|
|
mergedSettings[category] = {
|
|
renderInLists: value.renderInLists,
|
|
showTitle: value.showTitle,
|
|
postTemplateSlug: value.postTemplateSlug,
|
|
listTemplateSlug: value.listTemplateSlug,
|
|
};
|
|
}
|
|
return mergedSettings;
|
|
}
|
|
|
|
private resolveListExcludedCategories(categorySettings: Record<string, CategoryRenderSettings>): string[] {
|
|
return Object.entries(categorySettings)
|
|
.filter(([, settings]) => settings.renderInLists === false)
|
|
.map(([category]) => category);
|
|
}
|
|
|
|
private respond(res: ServerResponse, status: number, body: string): void {
|
|
res.statusCode = status;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.end(body);
|
|
}
|
|
|
|
private respondAsset(res: ServerResponse, contentType: string, body: Buffer): void {
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', contentType);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.end(body);
|
|
}
|
|
}
|
|
|
|
function buildNoopMacroRenderer(): PythonMacroRendererContract {
|
|
return {
|
|
async getEnabledMacroScripts() {
|
|
return [];
|
|
},
|
|
async renderMacro() {
|
|
throw new Error('Python macro renderer not configured');
|
|
},
|
|
};
|
|
}
|