Feature/post media translations (#42)

* chore: updated todo with translation ideas

* feat: first take at the implementation of translations

* fix: small addition for the translation feature

* feat: support language switching in the editor and preview

* feat: better handling of long bodies by not running them through a json envelope

* fix: unknown macros have better fallback

* feat: api for python to get translations

* fix: strip dumb prefix of content in translation

* feat: extend meta diff for translations

* feat: hook up translations to rebuild-from-disk

* feat: generation of the website prefers project language, falling back to canonical language

* fix: crashes during rendering

* feat: translation validation report

* fix: made the translation validation actually work

* chore: reorganization of menu

* fix: some topics cleanup

* chore: updated doc

* feat: translations for media

* feat: more aligned in UI/UX

* feat: edit translations possible

* chore: added full multi-language todo

* chore: updated todo for clarity

* feat: implementation of full multi-linguality

* fix: page creation creates pages

* fix: flags on every page

* fix: better prompt

* feat: made MCP server aware of language content

* feat: python tools for translations

* fix: better fill-in-translations

* fix: better prompt for translation. maybe.

* fix: losing posts from search due to translation process

* fix: translation validation handles in-db content and fill-in of missing translations fixed to flush

* fix: faster scanning for infilling of missing translations

* chore: updated agent instructions

* feat: calendar and tag cloud respect current language now

* fix: retries going up

* fix: got metadata-diff and rebuild into sync

* fix: extended meta-diff for timestamps

* fix: made website validation look at translated content, too

* fix: multi-lingual search

* chore: refactor Editor.tsx into two separate editors

* feat: do language detection when no explicit language given

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-09 14:43:18 +01:00
committed by GitHub
parent f1c9038803
commit b855d61524
116 changed files with 19954 additions and 2094 deletions

View File

@@ -1,6 +1,6 @@
import * as path from 'path';
import * as fs from 'fs/promises';
import type { PostEngine, PostData } from './PostEngine';
import type { PostData, PostTranslationData } from './PostEngine';
import type { MediaEngine, MediaData } from './MediaEngine';
import type { PostMediaEngine } from './PostMediaEngine';
import {
@@ -15,7 +15,7 @@ import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '..
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import { loadPublishedGenerationSets } from './GenerationPostSnapshotService';
import { buildCalendarArchiveData, buildSitemapAndFeeds, collectSitemapArchiveMetadata } from './GenerationSitemapFeedService';
import { buildCalendarArchiveData, buildSitemapAndFeeds, collectSitemapArchiveMetadata, buildMultiLanguageSitemap } from './GenerationSitemapFeedService';
import { buildTargetedValidationPlan, planMissingValidationPaths } from './ValidationApplyPlannerService';
import { compareSitemapToHtml } from './SiteValidationDiffService';
import {
@@ -58,6 +58,7 @@ export interface BlogGenerationOptions {
baseUrl: string;
maxPostsPerPage?: number;
language?: string;
blogLanguages?: string[];
pageTitle?: string;
picoTheme?: PicoThemeName;
categoryMetadata?: Record<string, CategoryMetadata>;
@@ -194,17 +195,79 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
type PublishedTranslationVariant = PostData & {
translationSourceSlug: string;
translationCanonicalLanguage?: string;
translationFilePath: string;
};
interface BlogGenerationPostEngineContract {
getPostsFiltered: (filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }) => Promise<PostData[]>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
getPost: (postId: string) => Promise<PostData | null>;
hasPublishedVersion: (postId: string) => Promise<boolean>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
getPostTranslations?: (postId: string) => Promise<PostTranslationData[]>;
setProjectContext: (projectId: string, dataDir?: string) => void;
}
export class BlogGenerationEngine {
private readonly postEngine: PostEngine;
private readonly postEngine: BlogGenerationPostEngineContract;
private readonly mediaEngine: MediaEngine;
private readonly postMediaEngine: PostMediaEngine;
constructor(postEngine: PostEngine, mediaEngine: MediaEngine, postMediaEngine: PostMediaEngine) {
constructor(postEngine: BlogGenerationPostEngineContract, mediaEngine: MediaEngine, postMediaEngine: PostMediaEngine) {
this.postEngine = postEngine;
this.mediaEngine = mediaEngine;
this.postMediaEngine = postMediaEngine;
}
private buildPublishedTranslationVariant(sourcePost: PostData, translation: PostTranslationData): PublishedTranslationVariant {
const canonicalLanguage = typeof sourcePost.language === 'string' ? sourcePost.language.trim() : '';
const variantLanguages = Array.from(new Set([
canonicalLanguage,
...(Array.isArray(sourcePost.availableLanguages) ? sourcePost.availableLanguages : []),
translation.language,
].filter((language) => typeof language === 'string' && language.trim().length > 0)));
return {
...sourcePost,
id: translation.id,
slug: `${sourcePost.slug}.${translation.language}`,
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
language: translation.language,
updatedAt: translation.updatedAt,
publishedAt: translation.publishedAt ?? sourcePost.publishedAt,
availableLanguages: variantLanguages,
translationSourceSlug: sourcePost.slug,
translationCanonicalLanguage: canonicalLanguage || undefined,
translationFilePath: translation.filePath,
};
}
private async buildPublishedRoutePosts(publishedPosts: PostData[]): Promise<PostData[]> {
const routePosts: PostData[] = [...publishedPosts];
if (typeof this.postEngine.getPostTranslations !== 'function') {
return routePosts;
}
for (const post of publishedPosts) {
const translations = await this.postEngine.getPostTranslations(post.id);
for (const translation of translations) {
if (translation.status !== 'published') {
continue;
}
routePosts.push(this.buildPublishedTranslationVariant(post, translation));
}
}
return routePosts;
}
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
onProgress(0, 'Loading posts...');
@@ -226,6 +289,7 @@ export class BlogGenerationEngine {
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
onProgress(3, `Found ${publishedPosts.length} published posts`);
@@ -250,7 +314,7 @@ export class BlogGenerationEngine {
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedPosts: publishedRoutePosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: true,
@@ -273,7 +337,7 @@ export class BlogGenerationEngine {
const archiveMetadata = collectSitemapArchiveMetadata({
baseUrl: options.baseUrl,
maxPostsPerPage,
publishedPosts,
publishedPosts: publishedRoutePosts,
publishedListPosts,
});
@@ -368,7 +432,7 @@ export class BlogGenerationEngine {
const renderRoute = createPreviewBackedGenerationRouteRenderer({
options,
maxPostsPerPage,
publishedPostsForLookup: publishedPosts,
publishedPostsForLookup: publishedRoutePosts,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
@@ -400,7 +464,7 @@ export class BlogGenerationEngine {
});
pagesGenerated += await generatePageRoutes({
projectId: options.projectId,
posts: publishedPosts,
posts: publishedRoutePosts,
renderRoute,
writePage,
onPageGenerated: reportUnitProgress,
@@ -411,7 +475,7 @@ export class BlogGenerationEngine {
onProgress(35, 'Generating single post pages...');
pagesGenerated += await generateSinglePostPages({
projectId: options.projectId,
posts: publishedPosts,
posts: publishedRoutePosts,
renderRoute,
writePage,
onPageGenerated: reportUnitProgress,
@@ -464,6 +528,173 @@ export class BlogGenerationEngine {
});
}
// --- Alternative language subtree generation ---
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (options.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
for (const lang of additionalLanguages) {
onProgress(85, `Generating ${lang} language subtree...`);
// Filter out doNotTranslate posts
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langPostIndex = buildGenerationPostIndex(langListPosts);
const langArchiveMetadata = collectSitemapArchiveMetadata({
baseUrl: options.baseUrl,
maxPostsPerPage,
publishedPosts: langPosts,
publishedListPosts: langListPosts,
});
// Build per-language feeds
if (includeCore) {
const langFeedResult = buildSitemapAndFeeds({
baseUrl: `${options.baseUrl}/${lang}`,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts: langPosts,
publishedListPosts: langListPosts,
postIndex: langPostIndex,
includeFeeds: true,
feedLanguage: lang,
});
const langRssPath = path.join(htmlDir, lang, 'rss.xml');
const langAtomPath = path.join(htmlDir, lang, 'atom.xml');
await fs.mkdir(path.join(htmlDir, lang), { recursive: true });
await writeFileIfHashChanged({ projectId: options.projectId, filePath: langRssPath, relativePath: `${lang}/rss.xml`, content: langFeedResult.rssXml });
await writeFileIfHashChanged({ projectId: options.projectId, filePath: langAtomPath, relativePath: `${lang}/atom.xml`, content: langFeedResult.atomXml });
}
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
options: { ...options, language: lang },
maxPostsPerPage,
publishedPostsForLookup: langPosts,
languagePrefix: `/${lang}`,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
postMediaEngine: this.postMediaEngine,
},
});
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
projectId,
htmlDir,
urlPath: `${lang}/${urlPath}`,
content,
knownDirectories: knownOutputDirectories,
hashCache: generatedHashCache,
refreshHashTimestampOnUnchanged: true,
});
const langReportProgress = (message: string) => reportUnitProgress(`[${lang}] ${message}`);
if (includeCore) {
pagesGenerated += await generateRootPages({
projectId: options.projectId,
posts: langListPosts,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
});
pagesGenerated += await generatePageRoutes({
projectId: options.projectId,
posts: langPosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
});
}
if (includeSingle) {
pagesGenerated += await generateSinglePostPages({
projectId: options.projectId,
posts: langPosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
});
}
if (includeCategory) {
pagesGenerated += await generateCategoryPages({
projectId: options.projectId,
posts: langListPosts,
allCategories: langArchiveMetadata.allCategories,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
postsByCategory: langPostIndex.postsByCategory,
});
}
if (includeTag) {
pagesGenerated += await generateTagPages({
projectId: options.projectId,
posts: langListPosts,
allTags: langArchiveMetadata.allTags,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
postsByTag: langPostIndex.postsByTag,
});
}
if (includeDate) {
pagesGenerated += await generateDateArchivePages({
projectId: options.projectId,
posts: langListPosts,
yearsMap: langArchiveMetadata.years,
yearMonthsMap: langArchiveMetadata.yearMonths,
yearMonthDaysMap: langArchiveMetadata.yearMonthDays,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated: langReportProgress,
postsByYear: langPostIndex.postsByYear,
postsByYearMonth: langPostIndex.postsByYearMonth,
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
});
}
}
// --- Combined sitemap with hreflang (if multiple languages) ---
if (includeCore && additionalLanguages.length > 0) {
const allLanguages = [mainLanguage, ...additionalLanguages];
const langFilteredPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const doNotTranslateIds = new Set(
publishedPosts
.filter((p) => (p as PostData & { doNotTranslate?: boolean }).doNotTranslate)
.map((p) => p.id),
);
const hreflangSitemapXml = buildMultiLanguageSitemap({
baseUrl: options.baseUrl,
mainLanguage,
allLanguages,
translatablePosts: langFilteredPosts,
doNotTranslatePosts: publishedPosts.filter((p) => doNotTranslateIds.has(p.id)),
publishedListPosts,
maxPostsPerPage,
postIndex: generationPostIndex,
});
sitemapWritten = await writeFileIfHashChanged({
projectId: options.projectId,
filePath: sitemapPath,
relativePath: 'sitemap.xml',
content: hreflangSitemapXml,
});
}
onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`);
return {
@@ -535,6 +766,7 @@ export class BlogGenerationEngine {
.map(([category]) => category);
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
const { sitemapXml } = buildSitemapAndFeeds({
@@ -542,7 +774,7 @@ export class BlogGenerationEngine {
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedPosts: publishedRoutePosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: false,
@@ -551,20 +783,111 @@ export class BlogGenerationEngine {
const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true });
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
// --- Build per-language expected paths ---
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (options.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
let sitemapToWrite = sitemapXml;
const additionalExpectedPaths: string[] = [];
const additionalPostTimestampChecks: Array<{
postUrlPath: string;
postFilePath: string;
generatedUpdatedAtMs?: number;
}> = [];
if (additionalLanguages.length > 0) {
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
for (const lang of additionalLanguages) {
const langPostIndex = buildGenerationPostIndex(langListPosts);
const langSitemapResult = buildSitemapAndFeeds({
baseUrl: `${options.baseUrl}/${lang}`,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts: langPosts,
publishedListPosts: langListPosts,
postIndex: langPostIndex,
includeFeeds: false,
});
// Extract expected paths from the per-language sitemap, stripping base URL
const langLocMatches = langSitemapResult.sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g);
for (const match of langLocMatches) {
const loc = match[1]?.trim();
if (!loc) continue;
try {
const locUrl = new URL(loc);
const base = new URL(options.baseUrl);
let locPath = locUrl.pathname.replace(/\/+$/, '');
const basePath = base.pathname.replace(/\/+$/, '');
if (basePath && locPath.startsWith(basePath)) {
locPath = locPath.slice(basePath.length);
}
additionalExpectedPaths.push(locPath || '/');
} catch {
additionalExpectedPaths.push(loc);
}
}
// Build per-language post timestamp checks
for (const post of langPosts) {
const createdAt = resolvePostCreatedAt(post);
const year = String(createdAt.getFullYear());
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const postFilePath = path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
const postUrlPath = `/${lang}${buildCanonicalPostPath(post)}`;
const relativePath = `${postUrlPath.replace(/^\//, '')}/index.html`;
const generatedRecord = await getGeneratedFileHashRecord(options.projectId, relativePath);
additionalPostTimestampChecks.push({
postUrlPath,
postFilePath,
generatedUpdatedAtMs: generatedRecord?.updatedAt,
});
}
}
// Write multi-language sitemap
const allLanguages = [mainLanguage, ...additionalLanguages];
const langFilteredPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const doNotTranslateIds = new Set(
publishedPosts
.filter((p) => (p as PostData & { doNotTranslate?: boolean }).doNotTranslate)
.map((p) => p.id),
);
sitemapToWrite = buildMultiLanguageSitemap({
baseUrl: options.baseUrl,
mainLanguage,
allLanguages,
translatablePosts: langFilteredPosts,
doNotTranslatePosts: publishedPosts.filter((p) => doNotTranslateIds.has(p.id)),
publishedListPosts,
maxPostsPerPage,
postIndex: generationPostIndex,
});
}
const sitemapChanged = await writeFileIfHashChanged({
projectId: options.projectId,
filePath: sitemapPath,
relativePath: 'sitemap.xml',
content: sitemapXml,
content: sitemapToWrite,
});
onProgress(50, 'Comparing sitemap to html pages...');
const postTimestampChecks = await Promise.all(publishedPosts.map(async (post) => {
const postTimestampChecks = await Promise.all(publishedRoutePosts.map(async (post) => {
const createdAt = resolvePostCreatedAt(post);
const year = String(createdAt.getFullYear());
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const postFilePath = path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
const postFilePath = (post as PublishedTranslationVariant).translationFilePath
?? path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
const postUrlPath = buildCanonicalPostPath(post);
const relativePath = `${postUrlPath.replace(/^\//, '')}/index.html`;
const generatedRecord = await getGeneratedFileHashRecord(options.projectId, relativePath);
@@ -580,7 +903,8 @@ export class BlogGenerationEngine {
sitemapXml,
baseUrl: options.baseUrl,
htmlDir,
postTimestampChecks,
postTimestampChecks: [...postTimestampChecks, ...additionalPostTimestampChecks],
additionalExpectedPaths,
});
onProgress(
@@ -613,7 +937,12 @@ export class BlogGenerationEngine {
onProgress(10, 'Planning validation apply steps...');
const missingPathPlan = planMissingValidationPaths(rerenderPaths);
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (options.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
const missingPathPlan = planMissingValidationPaths(rerenderPaths, additionalLanguages);
onProgress(20, 'Deleting extra URLs...');
@@ -687,13 +1016,14 @@ export class BlogGenerationEngine {
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
const { allCategories, allTags, years, yearMonths, yearMonthDays } = buildApplyValidationArchives(publishedListPosts);
const targetedPlan = buildTargetedValidationPlan({
initialPlan: missingPathPlan,
publishedPosts,
publishedPosts: publishedRoutePosts,
allCategories,
allTags,
availableYearMonths: yearMonths.keys(),
@@ -706,7 +1036,7 @@ export class BlogGenerationEngine {
const renderRoute = createPreviewBackedGenerationRouteRenderer({
options,
maxPostsPerPage,
publishedPostsForLookup: publishedPosts,
publishedPostsForLookup: publishedRoutePosts,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
@@ -725,7 +1055,7 @@ export class BlogGenerationEngine {
};
const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({
publishedPosts,
publishedPosts: publishedRoutePosts,
requestedPostIds: targetedPlan.requestedPostIds,
requestedPageSlugs: targetedPlan.requestedPageSlugs,
});
@@ -819,6 +1149,136 @@ export class BlogGenerationEngine {
postsByYearMonthDay: generationPostIndex.postsByYearMonthDay,
});
}
// --- Render missing per-language subtree pages ---
for (const [lang, langMissingPlan] of missingPathPlan.languagePlans) {
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
const langPostIndex = buildGenerationPostIndex(langListPosts);
const langArchives = buildApplyValidationArchives(langListPosts);
const langTargetedPlan = buildTargetedValidationPlan({
initialPlan: langMissingPlan,
publishedPosts: langPosts,
allCategories: langArchives.allCategories,
allTags: langArchives.allTags,
availableYearMonths: langArchives.yearMonths.keys(),
availableYearMonthDays: langArchives.yearMonthDays.keys(),
});
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
options: { ...options, language: lang },
maxPostsPerPage,
publishedPostsForLookup: langPosts,
languagePrefix: `/${lang}`,
engines: {
postEngine: this.postEngine,
mediaEngine: this.mediaEngine,
postMediaEngine: this.postMediaEngine,
},
});
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
projectId,
htmlDir,
urlPath: `${lang}/${urlPath}`,
content,
refreshHashTimestampOnUnchanged: true,
});
if (langTargetedPlan.requestRootRoutes) {
renderedUrlCount += await generateRootPages({
projectId: options.projectId,
posts: langListPosts,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
});
const langRequestedPagePosts = selectRequestedPosts({
publishedPosts: langPosts,
requestedPostIds: new Set(),
requestedPageSlugs: langTargetedPlan.requestedPageSlugs,
}).requestedPagePosts;
if (langRequestedPagePosts.length > 0) {
renderedUrlCount += await generatePageRoutes({
projectId: options.projectId,
posts: langRequestedPagePosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
});
}
}
if (langTargetedPlan.requestedCategorySet.size > 0) {
renderedUrlCount += await generateCategoryPages({
projectId: options.projectId,
posts: langListPosts,
allCategories: langTargetedPlan.requestedCategorySet,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
postsByCategory: langPostIndex.postsByCategory,
});
}
if (langTargetedPlan.requestedTagSet.size > 0) {
renderedUrlCount += await generateTagPages({
projectId: options.projectId,
posts: langListPosts,
allTags: langTargetedPlan.requestedTagSet,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
postsByTag: langPostIndex.postsByTag,
});
}
const langRequestedSinglePosts = selectRequestedPosts({
publishedPosts: langPosts,
requestedPostIds: langTargetedPlan.requestedPostIds,
requestedPageSlugs: new Set(),
}).requestedSinglePosts;
if (langRequestedSinglePosts.length > 0) {
renderedUrlCount += await generateSinglePostPages({
projectId: options.projectId,
posts: langRequestedSinglePosts,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
});
}
const langRequestedArchives = buildRequestedArchiveMaps({
requestedYears: langTargetedPlan.requestedYears,
requestedYearMonths: langTargetedPlan.requestedYearMonths,
requestedYearMonthDays: langTargetedPlan.requestedYearMonthDays,
years: langArchives.years,
yearMonths: langArchives.yearMonths,
yearMonthDays: langArchives.yearMonthDays,
});
if (langRequestedArchives.requestedYearsMap.size > 0 || langRequestedArchives.requestedYearMonthsMap.size > 0 || langRequestedArchives.requestedYearMonthDaysMap.size > 0) {
renderedUrlCount += await generateDateArchivePages({
projectId: options.projectId,
posts: langListPosts,
yearsMap: langRequestedArchives.requestedYearsMap,
yearMonthsMap: langRequestedArchives.requestedYearMonthsMap,
yearMonthDaysMap: langRequestedArchives.requestedYearMonthDaysMap,
maxPostsPerPage,
renderRoute: langRenderRoute,
writePage: langWritePage,
onPageGenerated,
postsByYear: langPostIndex.postsByYear,
postsByYearMonth: langPostIndex.postsByYearMonth,
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
});
}
}
}
if (renderedUrlCount > 0 || deletedUrlCount > 0) {

View File

@@ -7,6 +7,7 @@ import type { PostData } from './PostEngine';
import type { PicoThemeName } from '../shared/picoThemes';
import type { CategoryMetadata } from './BlogGenerationEngine';
import { PreviewServer } from './PreviewServer';
import type { PostTranslationData } from './PostEngine';
interface RenderContext {
projectContext: {
@@ -55,12 +56,14 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
};
maxPostsPerPage: number;
publishedPostsForLookup: PostData[];
languagePrefix?: string;
engines: {
postEngine: {
getPostsFiltered: (filter: Parameters<PreviewServer['renderRouteForContext']>[1] extends never ? never : any) => Promise<PostData[]>;
getPublishedVersion: (postId: string) => Promise<PostData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
getPost: (postId: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
hasPublishedVersion: (postId: string) => Promise<boolean>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
setProjectContext: (projectId: string, dataDir?: string) => void;
@@ -176,6 +179,9 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
return match ?? null;
},
getPost: (postId: string) => params.engines.postEngine.getPost(postId),
getPostTranslation: params.engines.postEngine.getPostTranslation
? (postId: string, language: string) => params.engines.postEngine.getPostTranslation!(postId, language)
: undefined,
hasPublishedVersion: (postId: string) => params.engines.postEngine.hasPublishedVersion(postId),
getLinkedBy: params.engines.postEngine.getLinkedBy
? (postId: string) => params.engines.postEngine.getLinkedBy!(postId)
@@ -218,7 +224,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
userTemplatesDir: path.join(params.options.dataDir, 'templates'),
});
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string>; languagePrefix?: string }> = (async () => {
const canonicalPostPathBySlug = new Map<string, string>();
for (const post of params.publishedPostsForLookup) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
@@ -241,6 +247,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
return {
canonicalPostPathBySlug,
canonicalMediaPathBySourcePath,
languagePrefix: params.languagePrefix,
};
})();

View File

@@ -17,6 +17,7 @@ interface BuildSitemapAndFeedsParams {
publishedListPosts: PostData[];
postIndex: GenerationPostIndexLike;
includeFeeds: boolean;
feedLanguage?: string;
}
export interface SitemapFeedBuildResult {
@@ -419,13 +420,14 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
` <title>${escapeXml(feedTitle)}</title>`,
` <link>${escapeXml(baseLink)}</link>`,
` <description>${escapeXml(feedDescription)}</description>`,
params.feedLanguage ? ` <language>${escapeXml(params.feedLanguage)}</language>` : null,
` <lastBuildDate>${feedUpdatedAt.toUTCString()}</lastBuildDate>`,
' <generator>bDS</generator>',
...rssItems,
' </channel>',
'</rss>',
'',
].join('\n');
].filter(Boolean).join('\n');
const atomEntries = feedPosts.map((post) => {
const createdAt = resolvePostCreatedAt(post);
@@ -455,9 +457,11 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
].filter(Boolean).join('\n');
});
const atomFeedLangAttr = params.feedLanguage ? ` xml:lang="${escapeXml(params.feedLanguage)}"` : '';
const atomXml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<feed xmlns="http://www.w3.org/2005/Atom">',
`<feed xmlns="http://www.w3.org/2005/Atom"${atomFeedLangAttr}>`,
` <title>${escapeXml(feedTitle)}</title>`,
` <subtitle>${escapeXml(feedDescription)}</subtitle>`,
` <id>${escapeXml(baseLink)}</id>`,
@@ -482,3 +486,170 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
feedPosts,
};
}
interface MultiLanguageSitemapParams {
baseUrl: string;
mainLanguage: string;
allLanguages: string[];
translatablePosts: PostData[];
doNotTranslatePosts: PostData[];
publishedListPosts: PostData[];
maxPostsPerPage: number;
postIndex: GenerationPostIndexLike;
}
function buildHreflangLinks(baseUrl: string, urlPath: string, mainLanguage: string, languages: string[]): string[] {
const links: string[] = [];
for (const lang of languages) {
const prefix = lang === mainLanguage ? '' : `/${lang}`;
const href = `${baseUrl}${prefix}${urlPath}`;
const canonicalHref = href.endsWith('/') ? href : `${href}/`;
links.push(` <xhtml:link rel="alternate" hreflang="${escapeXml(lang)}" href="${escapeXml(canonicalHref)}" />`);
}
const xDefaultHref = `${baseUrl}${urlPath}`;
const canonicalXDefault = xDefaultHref.endsWith('/') ? xDefaultHref : `${xDefaultHref}/`;
links.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(canonicalXDefault)}" />`);
return links;
}
function buildMultiLanguageSitemapUrl(
loc: string,
lastmod: string,
changefreq: string,
priority: string,
hreflangLinks: string[],
): string {
const canonicalLoc = (() => {
try {
const parsed = new URL(loc);
if (!parsed.pathname.endsWith('/')) {
parsed.pathname = `${parsed.pathname}/`;
}
return parsed.toString();
} catch {
return loc.endsWith('/') ? loc : `${loc}/`;
}
})();
return [
' <url>',
` <loc>${escapeXml(canonicalLoc)}</loc>`,
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
` <changefreq>${changefreq}</changefreq>`,
` <priority>${priority}</priority>`,
...hreflangLinks,
' </url>',
].join('\n');
}
export function buildMultiLanguageSitemap(params: MultiLanguageSitemapParams): string {
const {
baseUrl,
mainLanguage,
allLanguages,
translatablePosts,
doNotTranslatePosts,
publishedListPosts,
maxPostsPerPage,
postIndex,
} = params;
const now = new Date().toISOString();
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
const urls: string[] = [];
// Root page — all languages
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0',
buildHreflangLinks(baseUrl, '/', mainLanguage, allLanguages),
));
// Root pagination
const totalListPages = Math.max(1, Math.ceil(publishedListPosts.length / maxPostsPerPage));
for (let page = 2; page <= totalListPages; page++) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/page/${page}`, latestPostUpdatedAt, 'daily', '0.9',
buildHreflangLinks(baseUrl, `/page/${page}`, mainLanguage, allLanguages),
));
}
// Translatable posts — all languages
for (const post of translatablePosts) {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}${canonicalPath}`, post.updatedAt.toISOString(), 'monthly', '0.8',
buildHreflangLinks(baseUrl, canonicalPath, mainLanguage, allLanguages),
));
}
// Do-not-translate posts — main language only
for (const post of doNotTranslatePosts) {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}${canonicalPath}`, post.updatedAt.toISOString(), 'monthly', '0.8',
buildHreflangLinks(baseUrl, canonicalPath, mainLanguage, [mainLanguage]),
));
}
// Page posts (category 'page') — respecting doNotTranslate
const allPublishedPosts = [...translatablePosts, ...doNotTranslatePosts];
for (const post of allPublishedPosts) {
const categories = Array.isArray(post.categories) ? post.categories : [];
if (!categories.includes('page')) continue;
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
if (trimmedSlug.length === 0) continue;
const isTranslatable = !(post as PostData & { doNotTranslate?: boolean }).doNotTranslate;
const langs = isTranslatable ? allLanguages : [mainLanguage];
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${trimmedSlug}`, post.updatedAt.toISOString(), 'weekly', '0.7',
buildHreflangLinks(baseUrl, `/${trimmedSlug}`, mainLanguage, langs),
));
}
// Archives — all languages
for (const [year, lastmod] of Array.from(postIndex.postsByYear.entries()).sort((a, b) => b[0] - a[0])) {
const lastmodStr = lastmod instanceof Date ? lastmod.toISOString() : latestPostUpdatedAt;
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${year}`, lastmodStr, 'monthly', '0.5',
buildHreflangLinks(baseUrl, `/${year}`, mainLanguage, allLanguages),
));
}
for (const [ym] of Array.from(postIndex.postsByYearMonth.entries()).sort().reverse()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${ym}`, latestPostUpdatedAt, 'monthly', '0.5',
buildHreflangLinks(baseUrl, `/${ym}`, mainLanguage, allLanguages),
));
}
for (const [ymd] of Array.from(postIndex.postsByYearMonthDay.entries()).sort().reverse()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/${ymd}`, latestPostUpdatedAt, 'monthly', '0.4',
buildHreflangLinks(baseUrl, `/${ymd}`, mainLanguage, allLanguages),
));
}
// Categories — all languages
for (const category of Array.from(postIndex.postsByCategory.keys()).sort()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6',
buildHreflangLinks(baseUrl, `/category/${encodeURIComponent(category)}`, mainLanguage, allLanguages),
));
}
// Tags — all languages
for (const tag of Array.from(postIndex.postsByTag.keys()).sort()) {
urls.push(buildMultiLanguageSitemapUrl(
`${baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6',
buildHreflangLinks(baseUrl, `/tag/${encodeURIComponent(tag)}`, mainLanguage, allLanguages),
));
}
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
...urls,
'</urlset>',
'',
].join('\n');
}

View File

@@ -622,6 +622,7 @@ export class ImportExecutionEngine extends EventEmitter {
publishedAt,
tags: resolvedTags,
categories: resolvedCategories,
availableLanguages: [],
};
// Write to filesystem first (for published posts)

View File

@@ -22,6 +22,7 @@ import type {
SearchResult,
PaginatedResult,
PaginationOptions,
PostTranslationData,
} from './PostEngine';
import type { MediaData } from './MediaEngine';
import type { CreateScriptInput, ScriptData, ScriptValidationResult } from './ScriptEngine';
@@ -76,6 +77,8 @@ interface PostEngineContract {
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
getPostTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPostTranslations: (postId: string) => Promise<PostTranslationData[]>;
}
interface MediaEngineContract {
@@ -174,6 +177,7 @@ export class MCPServer {
this.registerResources(server);
this.registerResourceTemplates(server);
this.registerReadTools(server);
this.registerMediaTranslationTools(server);
this.registerProposalTools(server);
this.registerAcceptDiscardTools(server);
this.registerPrompts(server);
@@ -512,6 +516,8 @@ export class MCPServer {
query: z.string().optional().describe('Full-text search query'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
language: z.string().optional().describe('Require posts that are available in this language'),
missingTranslationLanguage: z.string().optional().describe('Require posts missing this translation language'),
year: z.number().optional().describe('Filter by year'),
month: z.number().optional().describe('Filter by month (1-12). Requires year.'),
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
@@ -527,7 +533,7 @@ export class MCPServer {
};
}
const hasFilters = args.category || args.tags || args.year || args.month || args.status;
const hasFilters = args.category || args.tags || args.language || args.missingTranslationLanguage || args.year || args.month || args.status;
const offset = args.offset ?? 0;
const limit = args.limit ?? 50;
@@ -543,6 +549,8 @@ export class MCPServer {
const filter: PostFilter = {};
if (args.category) filter.categories = [args.category];
if (args.tags) filter.tags = args.tags;
if (args.language) filter.language = args.language;
if (args.missingTranslationLanguage) filter.missingTranslationLanguage = args.missingTranslationLanguage;
if (args.year) filter.year = args.year;
if (args.month) filter.month = args.month;
if (args.status) filter.status = args.status;
@@ -611,9 +619,10 @@ export class MCPServer {
// ── read_post_by_slug ──
server.registerTool('read_post_by_slug', {
title: 'Read Post by Slug',
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks and outlinks. Useful when you know the slug but not the ID.',
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks and outlinks. Optionally specify a language to read a translation instead of the canonical post.',
inputSchema: {
slug: z.string().describe('The slug of the post to read'),
language: z.string().optional().describe('Language code to read a specific translation (e.g., "en", "fr"). Omit to read the canonical post.'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
@@ -624,6 +633,38 @@ export class MCPServer {
isError: true,
};
}
// If a language is requested and it differs from the canonical language, fetch translation
if (args.language && args.language !== post.language) {
const translation = await this.deps.postEngine.getPostTranslation(post.id, args.language);
if (!translation) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `No ${args.language} translation found for "${args.slug}"` }) }],
isError: true,
};
}
const [backlinks, linksTo] = await Promise.all([
this.deps.postEngine.getLinkedBy(post.id),
this.deps.postEngine.getLinksTo(post.id),
]);
return {
content: [{ type: 'text' as const, text: JSON.stringify({
post: {
id: post.id, title: translation.title, slug: post.slug,
content: translation.content, excerpt: translation.excerpt,
status: post.status, author: post.author,
language: translation.language,
canonicalLanguage: post.language,
categories: post.categories, tags: post.tags, availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
},
}) }],
};
}
const [backlinks, linksTo] = await Promise.all([
this.deps.postEngine.getLinkedBy(post.id),
this.deps.postEngine.getLinksTo(post.id),
@@ -634,7 +675,7 @@ export class MCPServer {
id: post.id, title: post.title, slug: post.slug,
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
categories: post.categories, tags: post.tags, availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
@@ -645,6 +686,87 @@ export class MCPServer {
});
}
private registerMediaTranslationTools(server: McpServer): void {
// ── get_post_translations ──
server.registerTool('get_post_translations', {
title: 'Get Post Translations',
description: 'List all available translations for a blog post. Returns translation records with language, title, content, excerpt, and status.',
inputSchema: {
slug: z.string().describe('The slug of the canonical post'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
const post = await this.deps.postEngine.getPostBySlug(args.slug);
if (!post) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post with slug "${args.slug}" not found` }) }],
isError: true,
};
}
const translations = await this.deps.postEngine.getPostTranslations(post.id);
return {
content: [{ type: 'text' as const, text: JSON.stringify({
translations: translations.map(t => ({
id: t.id,
language: t.language,
title: t.title,
excerpt: t.excerpt,
content: t.content,
status: t.status,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
})),
}) }],
};
});
// ── get_media_translations ──
server.registerTool('get_media_translations', {
title: 'Get Media Translations',
description: 'List all available translations for a media item. Returns translation records with language, title, alt, and caption.',
inputSchema: {
mediaId: z.string().describe('The ID of the media item'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
const mediaEngine = this.deps.mediaEngine as import('./MediaEngine').MediaEngine;
const translations = await mediaEngine.getMediaTranslations(args.mediaId);
return { content: [{ type: 'text' as const, text: JSON.stringify({ translations }) }] };
});
// ── upsert_media_translation ──
registerAppTool(server, 'upsert_media_translation', {
title: 'Upsert Media Translation',
description: 'Create or update a translation of media metadata (title, alt text, caption) for a specific language.',
inputSchema: {
mediaId: z.string().describe('The ID of the media item to translate'),
language: z.string().describe('Target language code (e.g., "fr", "de", "es")'),
title: z.string().optional().describe('Translated title'),
alt: z.string().optional().describe('Translated alt text'),
caption: z.string().optional().describe('Translated caption'),
},
annotations: { readOnlyHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: 'ui://bds/review-media-translation' } },
}, async (args: { mediaId: string; language: string; title?: string; alt?: string; caption?: string }) => {
try {
const mediaEngine = this.deps.mediaEngine as import('./MediaEngine').MediaEngine;
const translation = await mediaEngine.upsertMediaTranslation(args.mediaId, args.language, {
title: args.title,
alt: args.alt,
caption: args.caption,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify({ translation }) }],
};
} catch (error) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to upsert media translation: ${error instanceof Error ? error.message : String(error)}` }) }],
isError: true,
};
}
});
}
private registerProposalTools(server: McpServer): void {
// ── draft_post ──
registerAppTool(server, 'draft_post', {

View File

@@ -6,7 +6,7 @@ import * as crypto from 'crypto';
import { eq, and, gte, lte, lt, desc } from 'drizzle-orm';
import { app } from 'electron';
import { getDatabase } from '../database';
import { media, Media, NewMedia, postMedia } from '../database/schema';
import { media, Media, NewMedia, postMedia, mediaTranslations } from '../database/schema';
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
import { CliNotifier, NoopNotifier } from './CliNotifier';
@@ -33,10 +33,24 @@ export interface MediaData {
alt?: string;
caption?: string;
author?: string;
language?: string;
createdAt: Date;
updatedAt: Date;
tags: string[];
linkedPostIds?: string[]; // Posts this media is linked to
availableLanguages: string[];
}
export interface MediaTranslationData {
id: string;
projectId: string;
translationFor: string;
language: string;
title?: string;
alt?: string;
caption?: string;
createdAt: Date;
updatedAt: Date;
}
export interface MediaMetadata {
@@ -50,6 +64,7 @@ export interface MediaMetadata {
alt?: string;
caption?: string;
author?: string;
language?: string;
createdAt: string;
updatedAt: string;
tags: string[];
@@ -62,6 +77,8 @@ export interface MediaFilter {
endDate?: Date;
year?: number;
month?: number;
language?: string;
missingTranslationLanguage?: string;
}
export interface MediaSearchResult {
@@ -348,6 +365,7 @@ export class MediaEngine extends EventEmitter {
alt: mediaData.alt,
caption: mediaData.caption,
author: mediaData.author,
language: mediaData.language,
createdAt: mediaData.createdAt.toISOString(),
updatedAt: mediaData.updatedAt.toISOString(),
tags: mediaData.tags,
@@ -369,6 +387,7 @@ export class MediaEngine extends EventEmitter {
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
if (metadata.author) lines.push(`author: "${metadata.author}"`);
if (metadata.language) lines.push(`language: ${metadata.language}`);
lines.push(`createdAt: ${metadata.createdAt}`);
lines.push(`updatedAt: ${metadata.updatedAt}`);
@@ -445,6 +464,9 @@ export class MediaEngine extends EventEmitter {
case 'author':
metadata.author = value;
break;
case 'language':
metadata.language = value;
break;
case 'createdAt':
metadata.createdAt = value;
break;
@@ -550,9 +572,11 @@ export class MediaEngine extends EventEmitter {
alt: metadata?.alt,
caption: metadata?.caption,
author: metadata?.author,
language: metadata?.language,
createdAt,
updatedAt,
tags: metadata?.tags || [],
availableLanguages: metadata?.language ? [metadata.language] : [],
};
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
@@ -578,6 +602,7 @@ export class MediaEngine extends EventEmitter {
alt: mediaData.alt,
caption: mediaData.caption,
author: mediaData.author,
language: mediaData.language,
filePath: destPath,
sidecarPath,
createdAt: mediaData.createdAt,
@@ -643,6 +668,7 @@ export class MediaEngine extends EventEmitter {
alt: updated.alt,
caption: updated.caption,
author: updated.author,
language: updated.language,
updatedAt: updated.updatedAt,
tags: JSON.stringify(updated.tags),
})
@@ -765,6 +791,22 @@ export class MediaEngine extends EventEmitter {
const { postMedia } = await import('../database/schema');
await db.delete(postMedia).where(eq(postMedia.mediaId, id));
// Delete media translations (cascade cleanup)
await db.delete(mediaTranslations).where(eq(mediaTranslations.translationFor, id));
// Delete translated sidecar files
try {
const mediaDir = path.dirname(existing.filePath);
const entries = await fs.readdir(mediaDir);
const basename = path.basename(existing.filePath);
for (const entry of entries) {
const translatedSidecarPattern = new RegExp(`^${basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.[a-z]{2}\\.meta$`);
if (translatedSidecarPattern.test(entry)) {
try { await fs.unlink(path.join(mediaDir, entry)); } catch { /* ignore */ }
}
}
} catch { /* directory may not exist */ }
await db.delete(media).where(eq(media.id, id));
// Delete from FTS index
@@ -783,6 +825,13 @@ export class MediaEngine extends EventEmitter {
return null;
}
const translations = await db.select().from(mediaTranslations).where(eq(mediaTranslations.translationFor, id)).all();
const canonicalLang = dbMedia.language || undefined;
const translationLangs = translations.map(t => t.language);
const availableLanguages = canonicalLang
? [canonicalLang, ...translationLangs]
: translationLangs.length > 0 ? translationLangs : [];
return {
id: dbMedia.id,
filename: dbMedia.filename,
@@ -795,9 +844,11 @@ export class MediaEngine extends EventEmitter {
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
author: dbMedia.author || undefined,
language: canonicalLang,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
availableLanguages,
};
}
@@ -822,9 +873,11 @@ export class MediaEngine extends EventEmitter {
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
author: dbMedia.author || undefined,
language: dbMedia.language || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
availableLanguages: [] as string[],
}));
}
@@ -884,9 +937,11 @@ export class MediaEngine extends EventEmitter {
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
author: dbMedia.author || undefined,
language: dbMedia.language || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
availableLanguages: [] as string[],
};
// Client-side filtering for tags (JSON array)
@@ -1033,6 +1088,10 @@ export class MediaEngine extends EventEmitter {
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
console.log(`Deleted post-media links for project ${this.currentProjectId}`);
// Delete all media translations for the current project
await db.delete(mediaTranslations).where(eq(mediaTranslations.projectId, this.currentProjectId));
console.log(`Deleted media translations for project ${this.currentProjectId}`);
// Delete all FTS entries for the current project
const client = getDatabase().getLocalClient();
if (client) {
@@ -1046,7 +1105,14 @@ export class MediaEngine extends EventEmitter {
onProgress(5, 'Scanning media directory...');
// Recursively find all .meta files in the media directory tree
// Canonical sidecars: <file>.meta Translation sidecars: <file>.<lang>.meta
const metaFiles: string[] = [];
const translationMetaFiles: string[] = [];
const isTranslationSidecar = (name: string) => {
// e.g. photo.jpg.fr.meta → parts = ['photo', 'jpg', 'fr', 'meta']
const parts = name.split('.');
return parts.length >= 4 && parts[parts.length - 1] === 'meta' && parts[parts.length - 2].length <= 5;
};
const scanDir = async (dir: string) => {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -1055,7 +1121,11 @@ export class MediaEngine extends EventEmitter {
if (entry.isDirectory()) {
await scanDir(fullPath);
} else if (entry.name.endsWith('.meta')) {
metaFiles.push(fullPath);
if (isTranslationSidecar(entry.name)) {
translationMetaFiles.push(fullPath);
} else {
metaFiles.push(fullPath);
}
}
}
} catch {
@@ -1101,6 +1171,8 @@ export class MediaEngine extends EventEmitter {
title: metadata.title,
alt: metadata.alt,
caption: metadata.caption,
author: metadata.author || null,
language: metadata.language || null,
filePath: mediaFilePath,
sidecarPath,
createdAt: new Date(metadata.createdAt),
@@ -1144,6 +1216,33 @@ export class MediaEngine extends EventEmitter {
}
}
// Import media translation sidecars
if (translationMetaFiles.length > 0) {
onProgress(92, `Importing ${translationMetaFiles.length} media translation(s)...`);
for (const tPath of translationMetaFiles) {
const tData = await this.readTranslatedSidecarFile(tPath);
if (tData?.translationFor && tData?.language) {
// Find the canonical media by id
const canonical = await db.select({ id: media.id }).from(media)
.where(eq(media.id, tData.translationFor))
.get();
if (canonical) {
await db.insert(mediaTranslations).values({
id: uuidv4(),
projectId: this.currentProjectId,
translationFor: tData.translationFor,
language: tData.language,
title: tData.title || null,
alt: tData.alt || null,
caption: tData.caption || null,
createdAt: new Date(),
updatedAt: new Date(),
});
}
}
}
}
onProgress(100, 'Database rebuild complete');
this.emit('databaseRebuilt');
},
@@ -1307,6 +1406,198 @@ export class MediaEngine extends EventEmitter {
await taskManager.runTask(task);
}
// ─── Media Translation CRUD ─────────────────────────────────────────
async getMediaTranslation(mediaId: string, language: string): Promise<MediaTranslationData | null> {
const db = getDatabase().getLocal();
const rows = await db.select().from(mediaTranslations)
.where(eq(mediaTranslations.translationFor, mediaId))
.all();
const row = rows.find(r => r.language === language.toLowerCase());
if (!row) return null;
return this.toMediaTranslationData(row);
}
async getMediaTranslations(mediaId: string): Promise<MediaTranslationData[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(mediaTranslations)
.where(eq(mediaTranslations.translationFor, mediaId))
.all();
return rows.map(r => this.toMediaTranslationData(r));
}
async upsertMediaTranslation(
mediaId: string,
language: string,
data: { title?: string; alt?: string; caption?: string },
): Promise<MediaTranslationData> {
const db = getDatabase().getLocal();
const normalizedLang = language.toLowerCase();
// Verify media exists
const mediaItem = await db.select().from(media).where(eq(media.id, mediaId)).get();
if (!mediaItem) {
throw new Error('Media item not found');
}
// Reject if language matches canonical
const canonicalLang = mediaItem.language?.toLowerCase();
if (canonicalLang && canonicalLang === normalizedLang) {
throw new Error('Translation language must differ from canonical media language');
}
const now = new Date();
// Check for existing translation
const existing = await this.getMediaTranslation(mediaId, normalizedLang);
if (existing) {
// Update existing
await db.update(mediaTranslations)
.set({
title: data.title ?? existing.title,
alt: data.alt ?? existing.alt,
caption: data.caption ?? existing.caption,
updatedAt: now,
})
.where(eq(mediaTranslations.id, existing.id));
const updated: MediaTranslationData = {
...existing,
title: data.title ?? existing.title,
alt: data.alt ?? existing.alt,
caption: data.caption ?? existing.caption,
updatedAt: now,
};
// Write translated sidecar
await this.writeTranslatedSidecarFile(mediaItem.filePath, updated);
this.emit('mediaTranslationUpdated', updated);
return updated;
}
// Create new
const id = uuidv4();
const newTranslation: MediaTranslationData = {
id,
projectId: this.currentProjectId,
translationFor: mediaId,
language: normalizedLang,
title: data.title,
alt: data.alt,
caption: data.caption,
createdAt: now,
updatedAt: now,
};
await db.insert(mediaTranslations).values({
id,
projectId: this.currentProjectId,
translationFor: mediaId,
language: normalizedLang,
title: data.title,
alt: data.alt,
caption: data.caption,
createdAt: now,
updatedAt: now,
});
// Write translated sidecar
await this.writeTranslatedSidecarFile(mediaItem.filePath, newTranslation);
this.emit('mediaTranslationCreated', newTranslation);
return newTranslation;
}
async deleteMediaTranslation(mediaId: string, language: string): Promise<boolean> {
const normalizedLang = language.toLowerCase();
const existing = await this.getMediaTranslation(mediaId, normalizedLang);
if (!existing) return false;
const db = getDatabase().getLocal();
await db.delete(mediaTranslations).where(eq(mediaTranslations.id, existing.id));
// Delete translated sidecar file
const mediaItem = await db.select().from(media).where(eq(media.id, mediaId)).get();
if (mediaItem) {
const sidecarPath = `${mediaItem.filePath}.${normalizedLang}.meta`;
try { await fs.unlink(sidecarPath); } catch { /* ignore */ }
}
this.emit('mediaTranslationDeleted', { mediaId, language: normalizedLang });
return true;
}
private toMediaTranslationData(row: typeof mediaTranslations.$inferSelect): MediaTranslationData {
return {
id: row.id,
projectId: row.projectId,
translationFor: row.translationFor,
language: row.language,
title: row.title || undefined,
alt: row.alt || undefined,
caption: row.caption || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ─── Translated Sidecar I/O ─────────────────────────────────────────
private async writeTranslatedSidecarFile(
mediaFilePath: string,
translation: MediaTranslationData,
): Promise<string> {
const sidecarPath = `${mediaFilePath}.${translation.language}.meta`;
const lines = [
'---',
`translationFor: ${translation.translationFor}`,
`language: ${translation.language}`,
];
if (translation.title) lines.push(`title: "${translation.title}"`);
if (translation.alt) lines.push(`alt: "${translation.alt}"`);
if (translation.caption) lines.push(`caption: "${translation.caption}"`);
lines.push('---');
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
return sidecarPath;
}
async readTranslatedSidecarFile(sidecarPath: string): Promise<{ translationFor?: string; language?: string; title?: string; alt?: string; caption?: string } | null> {
try {
try { await fs.access(sidecarPath); } catch { return null; }
const content = await fs.readFile(sidecarPath, 'utf-8');
const result: { translationFor?: string; language?: string; title?: string; alt?: string; caption?: string } = {};
for (const line of content.split('\n')) {
if (line === '---') continue;
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
switch (key) {
case 'translationFor': result.translationFor = value; break;
case 'language': result.language = value; break;
case 'title': result.title = value; break;
case 'alt': result.alt = value; break;
case 'caption': result.caption = value; break;
}
}
return result;
} catch {
return null;
}
}
}

View File

@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { posts, projects } from '../database/schema';
import { sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
import { SUPPORTED_RENDER_LANGUAGES, type SupportedLanguage } from '../shared/i18n';
import {
normalizeTaxonomyTerm,
normalizeNonEmptyTaxonomyTerm,
@@ -29,6 +30,7 @@ export interface ProjectMetadata {
categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
semanticSimilarityEnabled?: boolean; // Enable local ONNX embedding-based semantic similarity
blogLanguages?: string[]; // Languages the blog is rendered in (mainLanguage is always included)
}
export interface CategoryRenderSettings {
@@ -103,6 +105,19 @@ function sanitizeCategoryTitle(value: unknown, fallback: string): string {
type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRenderSettings>;
const supportedLanguageSet = new Set<string>(SUPPORTED_RENDER_LANGUAGES);
function sanitizeBlogLanguages(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const filtered = value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0 && supportedLanguageSet.has(item));
return filtered.length > 0 ? [...new Set(filtered)] : undefined;
}
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
@@ -112,6 +127,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
const pythonRuntimeMode = metadata.pythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker';
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
const blogLanguages = sanitizeBlogLanguages(metadata.blogLanguages);
return {
...metadata,
publicUrl,
@@ -121,6 +137,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
picoTheme,
categoryMetadata,
categorySettings: undefined,
blogLanguages,
};
}
@@ -349,6 +366,7 @@ export class MetaEngine extends EventEmitter {
picoTheme: normalizedUpdates.picoTheme,
categoryMetadata: normalizedUpdates.categoryMetadata,
semanticSimilarityEnabled: normalizedUpdates.semanticSimilarityEnabled,
blogLanguages: normalizedUpdates.blogLanguages,
});
} else {
this.projectMetadata = normalizeProjectMetadata({

View File

@@ -11,8 +11,9 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { eq, and } from 'drizzle-orm';
import { getDatabase } from '../database';
import { posts, media, scripts, templates } from '../database/schema';
import { posts, postTranslations, media, scripts, templates } from '../database/schema';
import { readPostFile, PostFileData } from './postFileUtils';
import { readPostTranslationFile } from './postTranslationFileUtils';
import { taskManager } from './TaskManager';
import type { PostEngine } from './PostEngine';
import type { MediaEngine } from './MediaEngine';
@@ -32,7 +33,7 @@ export interface FieldDifference<T = unknown> {
/**
* The fields that can have differences for posts
*/
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language';
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language' | 'translationFor' | 'doNotTranslate' | 'status' | 'templateSlug' | 'createdAt' | 'updatedAt' | 'publishedAt';
/**
* Metadata differences for a single post
@@ -41,6 +42,8 @@ export interface PostMetadataDiff {
postId: string;
title: string;
slug: string;
variant?: 'post' | 'translation';
translationLanguage?: string;
filePath?: string;
hasDifferences: boolean;
fileMissing?: boolean;
@@ -70,6 +73,9 @@ export interface OrphanFile {
slug: string;
title?: string;
id?: string;
variant?: 'post' | 'translation';
translationFor?: string;
language?: string;
}
/**
@@ -85,7 +91,7 @@ export interface ScanResult {
// ── Media diff types ──
export type MediaDiffField = 'title' | 'alt' | 'caption' | 'author' | 'tags';
export type MediaDiffField = 'title' | 'alt' | 'caption' | 'author' | 'tags' | 'language';
export interface MediaMetadataDiff {
mediaId: string;
@@ -354,7 +360,80 @@ export class MetadataDiffEngine extends EventEmitter {
.get();
if (!dbPost) {
return null;
const dbTranslation = await db
.select()
.from(postTranslations)
.where(and(eq(postTranslations.id, postId), eq(postTranslations.projectId, this.currentProjectId)))
.get();
if (!dbTranslation) {
return null;
}
if (!dbTranslation.filePath || dbTranslation.status === 'draft') {
return null;
}
const sourcePost = await db
.select()
.from(posts)
.where(and(eq(posts.id, dbTranslation.translationFor), eq(posts.projectId, this.currentProjectId)))
.get();
const translationSlug = sourcePost?.slug
? `${sourcePost.slug}.${dbTranslation.language}`
: path.basename(dbTranslation.filePath, path.extname(dbTranslation.filePath));
const translationTitle = `${dbTranslation.title} [${dbTranslation.language}]`;
const translationFileData = await readPostTranslationFile(dbTranslation.filePath);
if (!translationFileData) {
const missingDiffs: Partial<Record<DiffField, FieldDifference>> = {
translationFor: { dbValue: dbTranslation.translationFor, fileValue: null },
language: { dbValue: dbTranslation.language, fileValue: null },
title: { dbValue: dbTranslation.title, fileValue: null },
};
if (dbTranslation.excerpt) {
missingDiffs.excerpt = { dbValue: dbTranslation.excerpt, fileValue: null };
}
return {
postId: dbTranslation.id,
title: translationTitle,
slug: translationSlug,
variant: 'translation',
translationLanguage: dbTranslation.language,
filePath: dbTranslation.filePath,
hasDifferences: true,
fileMissing: true,
differences: missingDiffs,
};
}
const translationDiffs: Partial<Record<DiffField, FieldDifference>> = {};
if (dbTranslation.translationFor !== translationFileData.translationFor) {
translationDiffs.translationFor = { dbValue: dbTranslation.translationFor, fileValue: translationFileData.translationFor };
}
if (dbTranslation.language !== translationFileData.language) {
translationDiffs.language = { dbValue: dbTranslation.language, fileValue: translationFileData.language };
}
if (dbTranslation.title !== translationFileData.title) {
translationDiffs.title = { dbValue: dbTranslation.title, fileValue: translationFileData.title };
}
if ((dbTranslation.excerpt || '') !== (translationFileData.excerpt || '')) {
translationDiffs.excerpt = { dbValue: dbTranslation.excerpt || '', fileValue: translationFileData.excerpt || '' };
}
return {
postId: dbTranslation.id,
title: translationTitle,
slug: translationSlug,
variant: 'translation',
translationLanguage: dbTranslation.language,
filePath: dbTranslation.filePath,
hasDifferences: Object.keys(translationDiffs).length > 0,
differences: translationDiffs,
};
}
// Skip drafts - they don't have files
@@ -375,6 +454,8 @@ export class MetadataDiffEngine extends EventEmitter {
if (dbPost.excerpt) missingDiffs.excerpt = { dbValue: dbPost.excerpt, fileValue: null };
if (dbPost.author) missingDiffs.author = { dbValue: dbPost.author, fileValue: null };
if (dbPost.language) missingDiffs.language = { dbValue: dbPost.language, fileValue: null };
if (dbPost.doNotTranslate) missingDiffs.doNotTranslate = { dbValue: true, fileValue: null };
if (dbPost.templateSlug) missingDiffs.templateSlug = { dbValue: dbPost.templateSlug, fileValue: null };
return {
postId: dbPost.id,
title: dbPost.title,
@@ -425,6 +506,35 @@ export class MetadataDiffEngine extends EventEmitter {
differences.language = { dbValue: dbPost.language || '', fileValue: fileData.language || '' };
}
// Compare doNotTranslate
const dbDoNotTranslate = Boolean(dbPost.doNotTranslate);
const fileDoNotTranslate = Boolean(fileData.doNotTranslate);
if (dbDoNotTranslate !== fileDoNotTranslate) {
differences.doNotTranslate = { dbValue: dbDoNotTranslate, fileValue: fileDoNotTranslate };
}
// Compare status
const fileStatus = fileData.status || 'published';
if (dbPost.status !== fileStatus) {
differences.status = { dbValue: dbPost.status, fileValue: fileStatus };
}
// Compare templateSlug
if ((dbPost.templateSlug || '') !== (fileData.templateSlug || '')) {
differences.templateSlug = { dbValue: dbPost.templateSlug || '', fileValue: fileData.templateSlug || '' };
}
// Compare timestamps (second precision — DB stores integer seconds)
if (!this.datesEqualSeconds(dbPost.createdAt, fileData.createdAt)) {
differences.createdAt = { dbValue: dbPost.createdAt?.toISOString() || '', fileValue: fileData.createdAt?.toISOString() || '' };
}
if (!this.datesEqualSeconds(dbPost.updatedAt, fileData.updatedAt)) {
differences.updatedAt = { dbValue: dbPost.updatedAt?.toISOString() || '', fileValue: fileData.updatedAt?.toISOString() || '' };
}
if (!this.datesEqualSeconds(dbPost.publishedAt, fileData.publishedAt)) {
differences.publishedAt = { dbValue: dbPost.publishedAt?.toISOString() || '', fileValue: fileData.publishedAt?.toISOString() || '' };
}
return {
postId: dbPost.id,
title: dbPost.title,
@@ -445,6 +555,13 @@ export class MetadataDiffEngine extends EventEmitter {
return sortedA.every((val, idx) => val === sortedB[idx]);
}
/** Compare two dates at second precision (SQLite stores integer seconds). */
private datesEqualSeconds(a: Date | null | undefined, b: Date | null | undefined): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
return Math.floor(a.getTime() / 1000) === Math.floor(b.getTime() / 1000);
}
/**
* Scan all published posts and find metadata differences.
* When postsBaseDir is provided, also scans the filesystem to detect
@@ -468,8 +585,19 @@ export class MetadataDiffEngine extends EventEmitter {
args: [this.currentProjectId],
});
const translationResult = await client.execute({
sql: `SELECT id, translation_for, language, title, excerpt, file_path
FROM post_translations
WHERE project_id = ?
AND status = 'published'
AND file_path IS NOT NULL
AND file_path != ''`,
args: [this.currentProjectId],
});
const publishedPosts = result.rows;
const total = publishedPosts.length;
const publishedTranslations = translationResult.rows;
const total = publishedPosts.length + publishedTranslations.length;
const differences: PostMetadataDiff[] = [];
onProgress(0, total, `Scanning ${total} published posts...`);
@@ -488,11 +616,28 @@ export class MetadataDiffEngine extends EventEmitter {
differences.push(diff);
}
if ((i + 1) % 10 === 0 || i === total - 1) {
if ((i + 1) % 10 === 0 || i === publishedPosts.length - 1) {
onProgress(i + 1, total, `Scanned ${i + 1}/${total} posts, found ${differences.length} with differences`);
}
}
for (let i = 0; i < publishedTranslations.length; i++) {
const row = publishedTranslations[i];
const translationId = row.id as string;
const filePath = row.file_path as string;
if (filePath) knownFilePaths.add(filePath);
const diff = await this.comparePostMetadata(translationId);
if (diff && diff.hasDifferences) {
differences.push(diff);
}
const processed = publishedPosts.length + i + 1;
if (processed % 10 === 0 || processed === total) {
onProgress(processed, total, `Scanned ${processed}/${total} posts, found ${differences.length} with differences`);
}
}
// Also include file_paths from non-published posts so we don't flag them as orphans
const allPostsResult = await client.execute({
sql: `SELECT file_path FROM posts WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
@@ -502,6 +647,14 @@ export class MetadataDiffEngine extends EventEmitter {
knownFilePaths.add(row.file_path as string);
}
const allTranslationsResult = await client.execute({
sql: `SELECT file_path FROM post_translations WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
args: [this.currentProjectId],
});
for (const row of allTranslationsResult.rows) {
knownFilePaths.add(row.file_path as string);
}
// Scan filesystem for orphan files
const orphanFiles = await this.findOrphanFiles(postsBaseDir, knownFilePaths, onProgress, total);
@@ -564,19 +717,31 @@ export class MetadataDiffEngine extends EventEmitter {
const slug = path.basename(filePath, path.extname(filePath));
let title: string | undefined;
let id: string | undefined;
let variant: 'post' | 'translation' | undefined;
let translationFor: string | undefined;
let language: string | undefined;
// Try to read frontmatter for metadata
try {
const fileData = await readPostFile(filePath);
if (fileData) {
title = fileData.title;
id = fileData.id;
const translationData = await readPostTranslationFile(filePath);
if (translationData) {
title = translationData.title;
variant = 'translation';
translationFor = translationData.translationFor;
language = translationData.language;
} else {
const fileData = await readPostFile(filePath);
if (fileData) {
title = fileData.title;
id = fileData.id;
variant = 'post';
}
}
} catch {
// Couldn't parse file, still report it as orphan
}
orphanFiles.push({ filePath, slug, title, id });
orphanFiles.push({ filePath, slug, title, id, variant, translationFor, language });
if ((i + 1) % 10 === 0 || i === orphanPaths.length - 1) {
onProgress(scannedSoFar + i + 1, scannedSoFar + orphanPaths.length,
@@ -600,6 +765,13 @@ export class MetadataDiffEngine extends EventEmitter {
excerpt: 'Excerpt',
author: 'Author',
language: 'Language',
translationFor: 'Translation Source',
doNotTranslate: 'Do Not Translate',
status: 'Status',
templateSlug: 'Template',
createdAt: 'Created At',
updatedAt: 'Updated At',
publishedAt: 'Published At',
};
for (const diff of diffs) {
@@ -641,7 +813,16 @@ export class MetadataDiffEngine extends EventEmitter {
return this.runSyncLoop(
postIds,
onProgress,
async (postId) => postEngine.syncPublishedPostFile(postId),
async (postId) => {
const syncedPost = await postEngine.syncPublishedPostFile(postId);
if (syncedPost) {
return true;
}
if (typeof postEngine.syncPublishedPostTranslationFile === 'function') {
return postEngine.syncPublishedPostTranslationFile(postId);
}
return false;
},
(postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to file:`
);
}
@@ -667,45 +848,99 @@ export class MetadataDiffEngine extends EventEmitter {
.where(and(eq(posts.id, postId), eq(posts.projectId, this.currentProjectId)))
.get();
if (!dbPost || !dbPost.filePath) {
if (dbPost?.filePath) {
const fileData = await readPostFile(dbPost.filePath);
if (!fileData) {
return false;
}
const updateData: Record<string, unknown> = {};
if (!field || field === 'tags') {
updateData.tags = JSON.stringify(fileData.tags || []);
}
if (!field || field === 'categories') {
updateData.categories = JSON.stringify(fileData.categories || []);
}
if (!field || field === 'title') {
updateData.title = fileData.title;
}
if (!field || field === 'excerpt') {
updateData.excerpt = fileData.excerpt || null;
}
if (!field || field === 'author') {
updateData.author = fileData.author || null;
}
if (!field || field === 'language') {
updateData.language = fileData.language || null;
}
if (!field || field === 'doNotTranslate') {
updateData.doNotTranslate = fileData.doNotTranslate === true;
}
if (!field || field === 'status') {
updateData.status = fileData.status || 'published';
}
if (!field || field === 'templateSlug') {
updateData.templateSlug = fileData.templateSlug || null;
}
if (!field || field === 'createdAt') {
updateData.createdAt = fileData.createdAt;
}
if (!field || field === 'updatedAt') {
updateData.updatedAt = fileData.updatedAt;
}
if (!field || field === 'publishedAt') {
updateData.publishedAt = fileData.publishedAt || null;
}
// For single-field syncs of non-timestamp fields, mark record as recently modified
if (field && field !== 'createdAt' && field !== 'updatedAt' && field !== 'publishedAt') {
updateData.updatedAt = new Date();
}
await db
.update(posts)
.set(updateData)
.where(eq(posts.id, postId));
return true;
}
const dbTranslation = await db
.select()
.from(postTranslations)
.where(and(eq(postTranslations.id, postId), eq(postTranslations.projectId, this.currentProjectId)))
.get();
if (!dbTranslation?.filePath) {
return false;
}
// Read file metadata
const fileData = await readPostFile(dbPost.filePath);
if (!fileData) {
const translationFileData = await readPostTranslationFile(dbTranslation.filePath);
if (!translationFileData) {
return false;
}
// Build update object based on field or all fields
const updateData: Record<string, unknown> = {
const translationUpdateData: Record<string, unknown> = {
updatedAt: new Date(),
};
if (!field || field === 'tags') {
updateData.tags = JSON.stringify(fileData.tags || []);
}
if (!field || field === 'categories') {
updateData.categories = JSON.stringify(fileData.categories || []);
}
if (!field || field === 'title') {
updateData.title = fileData.title;
}
if (!field || field === 'excerpt') {
updateData.excerpt = fileData.excerpt || null;
}
if (!field || field === 'author') {
updateData.author = fileData.author || null;
if (!field || field === 'translationFor') {
translationUpdateData.translationFor = translationFileData.translationFor;
}
if (!field || field === 'language') {
updateData.language = fileData.language || null;
translationUpdateData.language = translationFileData.language || null;
}
if (!field || field === 'title') {
translationUpdateData.title = translationFileData.title;
}
if (!field || field === 'excerpt') {
translationUpdateData.excerpt = translationFileData.excerpt || null;
}
// Update database
await db
.update(posts)
.set(updateData)
.where(eq(posts.id, postId));
.update(postTranslations)
.set(translationUpdateData)
.where(eq(postTranslations.id, postId));
return true;
},
@@ -738,6 +973,7 @@ export class MetadataDiffEngine extends EventEmitter {
if (dbMedia.alt) missingDiffs.alt = { dbValue: dbMedia.alt, fileValue: null };
if (dbMedia.caption) missingDiffs.caption = { dbValue: dbMedia.caption, fileValue: null };
if (dbMedia.author) missingDiffs.author = { dbValue: dbMedia.author, fileValue: null };
if (dbMedia.language) missingDiffs.language = { dbValue: dbMedia.language, fileValue: null };
const dbTags: string[] = JSON.parse(dbMedia.tags || '[]');
if (dbTags.length > 0) missingDiffs.tags = { dbValue: dbTags, fileValue: null };
return {
@@ -764,6 +1000,9 @@ export class MetadataDiffEngine extends EventEmitter {
if ((dbMedia.author || '') !== (sidecar.author || '')) {
differences.author = { dbValue: dbMedia.author || '', fileValue: sidecar.author || '' };
}
if ((dbMedia.language || '') !== (sidecar.language || '')) {
differences.language = { dbValue: dbMedia.language || '', fileValue: sidecar.language || '' };
}
const dbTags: string[] = JSON.parse(dbMedia.tags || '[]');
const sidecarTags = sidecar.tags || [];
@@ -829,6 +1068,7 @@ export class MetadataDiffEngine extends EventEmitter {
caption: 'Caption',
author: 'Author',
tags: 'Tags',
language: 'Language',
};
for (const diff of diffs) {
@@ -910,6 +1150,7 @@ export class MetadataDiffEngine extends EventEmitter {
if (!field || field === 'alt') updateData.alt = sidecar.alt || null;
if (!field || field === 'caption') updateData.caption = sidecar.caption || null;
if (!field || field === 'author') updateData.author = sidecar.author || null;
if (!field || field === 'language') updateData.language = sidecar.language || null;
if (!field || field === 'tags') updateData.tags = JSON.stringify(sidecar.tags || []);
await db.update(media).set(updateData).where(eq(media.id, mediaId));
@@ -1506,7 +1747,10 @@ export class MetadataDiffEngine extends EventEmitter {
for (let i = 0; i < filePaths.length; i++) {
try {
const imported = await postEngine.importOrphanFile(filePaths[i]);
const translationData = await readPostTranslationFile(filePaths[i]);
const imported = translationData && typeof postEngine.importOrphanTranslationFile === 'function'
? await postEngine.importOrphanTranslationFile(filePaths[i])
: await postEngine.importOrphanFile(filePaths[i]);
if (imported) {
success++;
} else {

View File

@@ -3,13 +3,14 @@ import fs from 'node:fs';
import { marked } from 'marked';
import { Liquid } from 'liquidjs';
import type { MediaData } from './MediaEngine';
import type { PostTranslationData } from './PostEngine';
import type { PostData } from './PostEngine';
import type { MenuDocument, MenuItemData } from './MenuEngine';
import { PICO_THEME_NAMES } from '../shared/picoThemes';
import { CODE_ENHANCEMENTS_RUNTIME_JS } from './assets/codeEnhancementsRuntime';
import { CALENDAR_RUNTIME_JS } from './assets/calendarRuntime';
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
import { resolveRenderLanguageFromProjectPreferences, translateRender, getRenderTranslations } from '../shared/i18n';
function readLocalAsset(filename: string): string {
const candidates = [
@@ -54,6 +55,7 @@ export interface PythonMacroRendererContract {
export interface HtmlRewriteContext {
canonicalPostPathBySlug: Map<string, string>;
canonicalMediaPathBySourcePath: Map<string, string>;
languagePrefix?: string;
}
export interface TemplatePostEntry {
@@ -98,6 +100,9 @@ export type DateArchiveContext = {
export interface PostListTemplateContext {
page_title: string;
language: string;
blog_languages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language: string;
language_prefix: string;
menu_items: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -133,9 +138,17 @@ export interface BacklinkEntry {
path: string;
}
export interface AlternateLinkEntry {
href: string;
hreflang: string;
}
export interface SinglePostTemplateContext {
page_title: string;
language: string;
blog_languages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language: string;
language_prefix: string;
menu_items: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -149,6 +162,7 @@ export interface SinglePostTemplateContext {
canonical_media_path_by_source_path: Record<string, string>;
post_data_json_by_id: Record<string, string>;
backlinks: BacklinkEntry[];
alternate_links: AlternateLinkEntry[];
}
export interface NotFoundTemplateContext {
@@ -175,6 +189,7 @@ export interface RoutePagination {
export interface MediaEngineContract {
getAllMedia: () => Promise<MediaData[]>;
getMediaTranslation?: (mediaId: string, language: string) => Promise<{ title?: string; alt?: string; caption?: string } | null>;
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
}
@@ -185,6 +200,7 @@ export interface PostMediaEngineContract {
export interface PostEngineContract {
getPost: (id: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise<PostData[]>;
}
@@ -815,6 +831,14 @@ export function rewriteRenderedHtmlUrls(html: string, rewriteContext: HtmlRewrit
});
}
export function applyLanguagePrefixToHtml(html: string, languagePrefix: string): string {
if (!languagePrefix) return html;
return html.replace(/\bhref=(['"])(\/(?!media\/|assets\/).*?)\1/gi, (_fullMatch, quote: string, href: string) => {
if (href.startsWith(languagePrefix + '/') || href === languagePrefix) return `href=${quote}${href}${quote}`;
return `href=${quote}${languagePrefix}${href}${quote}`;
});
}
export function renderMacro(
name: string,
params: Record<string, string>,
@@ -890,6 +914,7 @@ export async function replaceAllMacrosAsync(
renderLanguage: string,
pythonMacroRenderer?: PythonMacroRendererContract | null,
postDataJson?: string | null,
languagePrefix?: string,
): Promise<string> {
const macroRegex = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g;
const matches: Array<{ fullMatch: string; name: string; rawParams: string | undefined; start: number; end: number }> = [];
@@ -942,12 +967,15 @@ export async function replaceAllMacrosAsync(
const pythonScript = scriptsBySlug.get(normalizeMacroName(m.name));
if (pythonScript && pythonMacroRenderer) {
try {
const resolvedLang = resolveRenderLanguageFromProjectPreferences(renderLanguage);
const context = {
env: {
isPreview: false,
mainLanguage: renderLanguage,
languagePrefix: languagePrefix ?? '',
hook: m.name,
source: { kind: 'macro', id: pythonScript.id },
translations: getRenderTranslations(resolvedLang),
},
params: params,
};
@@ -967,7 +995,7 @@ export async function replaceAllMacrosAsync(
rendered.push('');
}
} else {
rendered.push('');
rendered.push(m.fullMatch);
}
}
@@ -1172,10 +1200,11 @@ export class PageRenderer {
return translateRender(resolved, key);
});
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown, languagePrefixArg: unknown) => {
const content = typeof value === 'string' ? value : '';
const postId = typeof postIdArg === 'string' ? postIdArg : '';
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en';
const langPrefix = typeof languagePrefixArg === 'string' ? languagePrefixArg : '';
const postDataJsonById = (postDataJsonByIdArg && typeof postDataJsonByIdArg === 'object' && !Array.isArray(postDataJsonByIdArg))
? postDataJsonByIdArg as Record<string, string>
: {};
@@ -1202,7 +1231,7 @@ export class PageRenderer {
: null;
const withMacros = await replaceAllMacrosAsync(
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson,
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson, langPrefix,
);
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
@@ -1247,6 +1276,9 @@ export class PageRenderer {
basePathname: string;
page_title: string;
language: string;
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language?: string;
language_prefix?: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -1377,6 +1409,9 @@ export class PageRenderer {
return {
page_title: options.page_title,
language: options.language,
blog_languages: options.blog_languages ?? [],
current_language: options.current_language ?? options.language,
language_prefix: options.language_prefix ?? '',
menu_items: options.menu_items ?? [],
pico_stylesheet_href: options.pico_stylesheet_href,
html_theme_attribute: options.html_theme_attribute,
@@ -1419,13 +1454,47 @@ export class PageRenderer {
};
}
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract): Promise<PostData> {
if (post.status === 'published' && !post.content) {
const fullPost = await postEngine.getPost(post.id);
return fullPost ?? post;
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract, preferredLanguage?: string): Promise<PostData> {
// Pre-built translation variants (from blog generation) already have content and
// translationSourceSlug set — skip hydration and language resolution entirely.
const variantPost = post as PostData & { translationSourceSlug?: string };
if (variantPost.translationSourceSlug) {
return post;
}
return post;
const hydratedPost = post.status === 'published' && !post.content
? (await postEngine.getPost(post.id)) ?? post
: post;
const requestedLanguage = preferredLanguage?.trim().toLowerCase();
const canonicalLanguage = hydratedPost.language?.trim().toLowerCase();
if (!requestedLanguage || requestedLanguage === canonicalLanguage || !postEngine.getPostTranslation) {
return hydratedPost;
}
const translation = await postEngine.getPostTranslation(hydratedPost.id, requestedLanguage);
if (!translation || !translation.content) {
return hydratedPost;
}
const availableLanguages = Array.from(new Set([
...(Array.isArray(hydratedPost.availableLanguages) ? hydratedPost.availableLanguages : []),
requestedLanguage,
canonicalLanguage,
].filter((language): language is string => typeof language === 'string' && language.length > 0)));
return {
...hydratedPost,
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
language: translation.language,
updatedAt: translation.updatedAt,
publishedAt: translation.publishedAt ?? hydratedPost.publishedAt,
availableLanguages,
translationSourceSlug: hydratedPost.slug,
translationCanonicalLanguage: canonicalLanguage || undefined,
} as PostData;
}
async renderPostList(
@@ -1438,6 +1507,9 @@ export class PageRenderer {
basePathname: string;
page_title: string;
language: string;
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language?: string;
language_prefix?: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -1452,7 +1524,7 @@ export class PageRenderer {
}
const renderablePosts = postEngine
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine)))
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine, options.language)))
: posts;
const templateContext = this.buildListTemplateContext(
renderablePosts,
@@ -1465,7 +1537,8 @@ export class PageRenderer {
routeCategory ?? undefined,
options.categorySettings as Record<string, { listTemplateSlug?: string | null }> | undefined,
);
return this.liquid.renderFile(listTemplateName, templateContext);
const html = await this.liquid.renderFile(listTemplateName, templateContext);
return rewriteContext.languagePrefix ? applyLanguagePrefixToHtml(html, rewriteContext.languagePrefix) : html;
}
async renderSinglePost(
@@ -1474,6 +1547,9 @@ export class PageRenderer {
pageContext: {
page_title: string;
language: string;
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
current_language?: string;
language_prefix?: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
@@ -1481,11 +1557,12 @@ export class PageRenderer {
tagSettings?: Record<string, { postTemplateSlug?: string | null }>;
categorySettings?: Record<string, { postTemplateSlug?: string | null }>;
backlinks?: BacklinkEntry[];
alternate_links?: AlternateLinkEntry[];
},
postEngine?: PostEngineContract,
): Promise<string> {
const renderablePost = postEngine
? await this.resolveRenderablePost(post, postEngine)
? await this.resolveRenderablePost(post, postEngine, pageContext.language)
: post;
const postCategories = Array.isArray(renderablePost.categories)
@@ -1503,6 +1580,9 @@ export class PageRenderer {
const context: SinglePostTemplateContext = {
...pageContext,
language: postLanguage || pageContext.language,
blog_languages: pageContext.blog_languages ?? [],
current_language: pageContext.current_language ?? pageContext.language,
language_prefix: pageContext.language_prefix ?? '',
menu_items: pageContext.menu_items ?? [],
post: {
id: renderablePost.id,
@@ -1523,6 +1603,7 @@ export class PageRenderer {
[renderablePost.id]: JSON.stringify(serializePostDataForMacro(renderablePost)),
},
backlinks: pageContext.backlinks ?? [],
alternate_links: pageContext.alternate_links ?? [],
};
const postTemplateName = resolvePostTemplateName(
@@ -1530,7 +1611,8 @@ export class PageRenderer {
pageContext.tagSettings,
pageContext.categorySettings,
);
return this.liquid.renderFile(postTemplateName, context);
const html = await this.liquid.renderFile(postTemplateName, context);
return rewriteContext.languagePrefix ? applyLanguagePrefixToHtml(html, rewriteContext.languagePrefix) : html;
}
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ 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 { type PostData, type PostFilter, type PostTranslationData } from './PostEngine';
import {
PageRenderer,
PREVIEW_ASSETS,
@@ -20,8 +20,10 @@ import {
type PythonMacroRendererContract,
} from './PageRenderer';
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
import { POST_LANGUAGE_FLAGS, type SupportedLanguage } from '../shared/i18n';
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
import {
findPublishedPostBySlug,
findSinglePostBySlug,
loadPostsForDayPage,
loadPublishedSnapshots,
@@ -40,6 +42,9 @@ interface ActiveProjectContext {
interface PostEngineContract {
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPost: (id: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPostTranslations?: (postId: string) => Promise<PostTranslationData[]>;
getPublishedTranslationLanguagesByPost?: () => Promise<Map<string, string[]>>;
hasPublishedVersion: (id: string) => Promise<boolean>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
@@ -86,6 +91,9 @@ export class PreviewServer {
private readonly tagColorByNameCache = new Map<string, Promise<Record<string, string>>>();
private server: Server | null = null;
private port: number | null = null;
private isStopping = false;
private inflightRequests = 0;
private drainResolve: (() => void) | null = null;
constructor(dependencies?: Partial<PreviewServerDependencies>) {
if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided');
@@ -141,6 +149,13 @@ export class PreviewServer {
}
async stop(): Promise<void> {
this.isStopping = true;
// Wait for in-flight requests to finish before closing the server.
if (this.inflightRequests > 0) {
await new Promise<void>((resolve) => { this.drainResolve = resolve; });
}
if (!this.server) {
this.port = null;
return;
@@ -183,7 +198,8 @@ export class PreviewServer {
requestTheme?: string | null;
htmlThemeAttribute?: string;
allowEmptyArchiveRender?: boolean;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
preferredLanguage?: string;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string };
},
): Promise<string | null> {
return renderRouteWithSharedContext(pathname, options, {
@@ -203,12 +219,18 @@ export class PreviewServer {
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),
findPublishedPostBySlug: (slug, dateFilter) => findPublishedPostBySlug(this.postEngine, slug, dateFilter),
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> {
if (this.isStopping) {
this.respond(res, 503, 'Service Unavailable');
return;
}
const remoteAddress = req.socket.remoteAddress;
const isLocal = remoteAddress === '127.0.0.1'
|| remoteAddress === '::1'
@@ -224,6 +246,7 @@ export class PreviewServer {
return;
}
this.inflightRequests++;
try {
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
@@ -251,7 +274,8 @@ export class PreviewServer {
const menuItems = buildTemplateMenuItems(menu, categoryMetadata);
const categorySettings = this.resolveCategorySettings(metadata);
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
const language = metadata?.mainLanguage?.trim() || 'en';
const requestLanguage = requestUrl.searchParams.get('lang')?.trim().toLowerCase() || undefined;
const language = requestLanguage || metadata?.mainLanguage?.trim() || 'en';
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
@@ -262,18 +286,52 @@ export class PreviewServer {
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
const htmlRewriteContext = await this.buildHtmlRewriteContext();
if (pathname === '/calendar.json') {
// Detect language-prefixed paths for alternative language previews
const mainLanguage = metadata?.mainLanguage?.trim().toLowerCase() || 'en';
const blogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
? (metadata as { blogLanguages: string[] }).blogLanguages
: [];
const alternativeLanguages = blogLanguages
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
let routePathname = pathname;
let languagePrefix = '';
let routeLanguage = requestLanguage;
const langPrefixMatch = pathname.match(/^\/([a-z]{2})(\/.*|$)/);
if (langPrefixMatch && alternativeLanguages.includes(langPrefixMatch[1])) {
languagePrefix = `/${langPrefixMatch[1]}`;
routeLanguage = langPrefixMatch[1];
routePathname = langPrefixMatch[2] || '/';
htmlRewriteContext.languagePrefix = languagePrefix;
}
if (pathname === '/calendar.json' || routePathname === '/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 rawBlogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
? (metadata as { blogLanguages: string[] }).blogLanguages
: [];
const allBlogLanguages = rawBlogLanguages.length > 0
? (rawBlogLanguages.includes(mainLanguage) ? rawBlogLanguages : [mainLanguage, ...rawBlogLanguages])
: [];
const stylePreviewBlogLanguages = allBlogLanguages.length > 0
? allBlogLanguages.map((lang) => ({
code: lang,
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
href_prefix: lang === mainLanguage ? '' : `/${lang}`,
is_current: lang === mainLanguage,
}))
: [];
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
pageTitle,
language,
menuItems,
picoStylesheetHref,
blogLanguages: stylePreviewBlogLanguages,
htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined,
}, categorySettings, listExcludedCategories);
this.respond(res, 200, stylePreviewHtml);
@@ -292,16 +350,20 @@ export class PreviewServer {
return;
}
const result = await this.renderRouteForContext(pathname, {
const result = await this.renderRouteForContext(routePathname, {
projectContext: context,
metadata,
menu,
htmlRewriteContext,
maxPostsPerPage,
requestTheme,
htmlThemeAttribute: undefined,
preferredLanguage: routeLanguage,
singlePostOptions: {
useDraftContent,
draftPostId,
lang: requestLanguage,
preferredLanguage: routeLanguage,
},
});
if (!result) {
@@ -320,12 +382,18 @@ export class PreviewServer {
} catch (error) {
console.error('[PreviewServer] Request failed:', error);
this.respond(res, 500, 'Internal Server Error');
} finally {
this.inflightRequests--;
if (this.inflightRequests === 0 && this.drainResolve) {
this.drainResolve();
this.drainResolve = null;
}
}
}
private async renderStylePreview(
rewriteContext: HtmlRewriteContext,
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; blogLanguages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>; htmlThemeAttribute?: string },
categorySettings: Record<string, CategoryRenderSettings>,
listExcludedCategories: string[],
): Promise<string> {
@@ -353,6 +421,7 @@ export class PreviewServer {
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -363,8 +432,20 @@ export class PreviewServer {
const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' });
const canonicalPostPathBySlug = new Map<string, string>();
const translationMap = this.postEngine.getPublishedTranslationLanguagesByPost
? await this.postEngine.getPublishedTranslationLanguagesByPost()
: new Map<string, string[]>();
for (const post of publishedPosts) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
const languages = translationMap.get(post.id);
if (languages) {
for (const lang of languages) {
const variantSlug = `${post.slug}.${lang}`;
canonicalPostPathBySlug.set(variantSlug, buildCanonicalPostPath({ ...post, slug: variantSlug }));
}
}
}
const canonicalMediaPathBySourcePath = new Map<string, string>();

View File

@@ -1,11 +1,13 @@
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import { getPicoStylesheetHref, sanitizePicoTheme } from '../shared/picoThemes';
import { POST_LANGUAGE_FLAGS, type SupportedLanguage } from '../shared/i18n';
import {
buildTemplateMenuItems,
clampMaxPostsPerPage,
parseRoutePagination,
resolvePageTitle,
type AlternateLinkEntry,
type BacklinkEntry,
type PostEngineContract,
type CategoryRenderSettings,
@@ -32,7 +34,8 @@ export interface SharedRouteRenderOptions {
requestTheme?: string | null;
htmlThemeAttribute?: string;
allowEmptyArchiveRender?: boolean;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
preferredLanguage?: string;
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string };
}
export interface SharedRouteRenderServices<TCategoryMetadata> {
@@ -77,9 +80,13 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
day: number,
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
) => Promise<{ posts: PostData[]; totalPosts: number }>;
findPublishedPostBySlug: (
slug: string,
dateFilter?: { year: number; month: number },
) => Promise<PostData | null>;
findSinglePostBySlug: (
slug: string,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string },
dateFilter?: { year: number; month: number; day?: number },
) => Promise<PostData | null>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
@@ -114,6 +121,48 @@ async function resolveBacklinks(
.filter((entry): entry is BacklinkEntry => entry !== null);
}
function resolveAlternateLinks(
post: PostData,
rewriteContext: HtmlRewriteContext,
): AlternateLinkEntry[] {
const variantPost = post as PostData & {
translationSourceSlug?: string;
translationCanonicalLanguage?: string;
};
const sourceSlug = typeof variantPost.translationSourceSlug === 'string' && variantPost.translationSourceSlug.trim().length > 0
? variantPost.translationSourceSlug.trim()
: post.slug;
const canonicalLanguage = typeof variantPost.translationCanonicalLanguage === 'string' && variantPost.translationCanonicalLanguage.trim().length > 0
? variantPost.translationCanonicalLanguage.trim()
: (post.language || '').trim();
const linkByLanguage = new Map<string, string>();
const currentLanguage = (post.language || canonicalLanguage).trim();
const currentHref = rewriteContext.canonicalPostPathBySlug.get(post.slug);
if (currentLanguage && currentHref) {
linkByLanguage.set(currentLanguage, currentHref);
}
const canonicalHref = rewriteContext.canonicalPostPathBySlug.get(sourceSlug);
if (canonicalLanguage && canonicalHref) {
linkByLanguage.set(canonicalLanguage, canonicalHref);
}
const languages = Array.from(new Set((Array.isArray(post.availableLanguages) ? post.availableLanguages : [])
.filter((language) => typeof language === 'string' && language.trim().length > 0)));
for (const language of languages) {
if (linkByLanguage.has(language)) {
continue;
}
const href = rewriteContext.canonicalPostPathBySlug.get(`${sourceSlug}.${language}`);
if (href) {
linkByLanguage.set(language, href);
}
}
return Array.from(linkByLanguage.entries()).map(([hreflang, href]) => ({ hreflang, href }));
}
async function resolveRouteWithSharedServices(
pathname: string,
maxPostsPerPage: number,
@@ -121,6 +170,9 @@ async function resolveRouteWithSharedServices(
pageContext: {
pageTitle: string;
language: string;
blogLanguages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
currentLanguage: string;
languagePrefix: string;
menuItems: ReturnType<typeof buildTemplateMenuItems>;
picoStylesheetHref: string;
htmlThemeAttribute?: string;
@@ -132,7 +184,7 @@ async function resolveRouteWithSharedServices(
listExcludedCategories: string[],
services: SharedRouteRenderServices<CategoryMetadata>,
allowEmptyArchiveRender: boolean,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string },
): Promise<string | null> {
const routePagination = parseRoutePagination(pathname);
if (!routePagination) {
@@ -157,6 +209,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -177,6 +232,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -198,6 +256,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -211,12 +272,18 @@ async function resolveRouteWithSharedServices(
const month = Number(daySlugMatch[2]);
const day = Number(daySlugMatch[3]);
const slug = daySlugMatch[4];
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
const post = await services.findSinglePostBySlug(slug, {
...singlePostOptions,
preferredLanguage: singlePostOptions?.preferredLanguage ?? pageContext.language,
}, { year, month, day });
if (!post) return null;
const backlinks = await resolveBacklinks(post.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -224,6 +291,7 @@ async function resolveRouteWithSharedServices(
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
alternate_links: resolveAlternateLinks(post, rewriteContext),
}, services.postEngineForMacros);
}
@@ -245,6 +313,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -267,6 +338,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -287,6 +361,9 @@ async function resolveRouteWithSharedServices(
categorySettings,
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -297,13 +374,17 @@ async function resolveRouteWithSharedServices(
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
if (pageSlugMatch) {
const slug = pageSlugMatch[1];
const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
if (!pagePost) return null;
const pagePost = await services.findPublishedPostBySlug(slug);
if (!pagePost || !(Array.isArray(pagePost.categories) && pagePost.categories.includes('page'))) {
return null;
}
const backlinks = await resolveBacklinks(pagePost.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
blog_languages: pageContext.blogLanguages,
current_language: pageContext.currentLanguage,
language_prefix: pageContext.languagePrefix,
menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute,
@@ -311,6 +392,7 @@ async function resolveRouteWithSharedServices(
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
alternate_links: resolveAlternateLinks(pagePost, rewriteContext),
}, services.postEngineForMacros);
}
@@ -343,7 +425,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
const menuItems = buildTemplateMenuItems(menu, categoryMetadata as Record<string, { title?: string }>);
const categorySettings = services.resolveCategorySettings(metadata ?? null);
const listExcludedCategories = services.resolveListExcludedCategories(categorySettings);
const language = metadata?.mainLanguage?.trim() || 'en';
const language = options.preferredLanguage?.trim().toLowerCase() || metadata?.mainLanguage?.trim() || 'en';
const pageTitle = resolvePageTitle(metadata ?? null, options.projectContext.projectName, options.projectContext.projectDescription);
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage ?? metadata?.maxPostsPerPage);
const appliedTheme = sanitizePicoTheme(options.requestTheme)
@@ -354,9 +436,32 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {};
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
const languagePrefix = htmlRewriteContext.languagePrefix ?? '';
const currentLanguage = languagePrefix
? languagePrefix.replace(/^\//, '')
: language;
const mainLang = metadata?.mainLanguage?.trim().toLowerCase() || 'en';
const rawBlogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
? (metadata as { blogLanguages: string[] }).blogLanguages
: [];
const allBlogLanguages = rawBlogLanguages.length > 0
? (rawBlogLanguages.includes(mainLang) ? rawBlogLanguages : [mainLang, ...rawBlogLanguages])
: [];
const blogLanguages = allBlogLanguages.length > 0
? allBlogLanguages.map((lang) => ({
code: lang,
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
href_prefix: lang === mainLang ? '' : `/${lang}`,
is_current: lang === currentLanguage,
}))
: [];
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
pageTitle,
language,
blogLanguages,
currentLanguage,
languagePrefix,
menuItems,
picoStylesheetHref,
htmlThemeAttribute: options.htmlThemeAttribute,

View File

@@ -1,12 +1,20 @@
import type { PostData, PostFilter } from './PostEngine';
import type { PostData, PostFilter, PostTranslationData } from './PostEngine';
export interface SharedSnapshotPostEngine {
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPost: (id: string) => Promise<PostData | null>;
getPublishedVersion: (id: string) => Promise<PostData | null>;
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
}
interface SinglePostPreviewOptions {
useDraftContent?: boolean;
draftPostId?: string;
lang?: string;
preferredLanguage?: string;
}
function buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
const baseFilter: PostFilter = {};
@@ -166,25 +174,64 @@ export async function findPublishedPostBySlug(
export async function findSinglePostBySlug(
postEngine: SharedSnapshotPostEngine,
slug: string,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
singlePostOptions?: SinglePostPreviewOptions,
dateFilter?: { year: number; month: number; day?: number },
): Promise<PostData | null> {
let resolvedPost: PostData | null = null;
if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) {
const draftCandidate = await postEngine.getPost(singlePostOptions.draftPostId);
if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) {
if (!dateFilter) {
return draftCandidate;
}
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
return draftCandidate;
resolvedPost = draftCandidate;
} else {
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
if (sameYear && sameMonth && sameDay) {
resolvedPost = draftCandidate;
}
}
}
}
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
return findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
if (!resolvedPost) {
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
resolvedPost = await findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
}
if (!resolvedPost) {
return null;
}
const requestedLanguage = (singlePostOptions?.lang ?? singlePostOptions?.preferredLanguage)?.trim().toLowerCase();
if (!requestedLanguage || requestedLanguage === (resolvedPost.language || '').trim().toLowerCase() || !postEngine.getPostTranslation) {
return resolvedPost;
}
const translation = await postEngine.getPostTranslation(resolvedPost.id, requestedLanguage);
if (!translation) {
return resolvedPost;
}
const availableLanguages = Array.from(new Set([
...((Array.isArray(resolvedPost.availableLanguages) ? resolvedPost.availableLanguages : [])),
requestedLanguage,
(resolvedPost.language || '').trim(),
].filter((language): language is string => typeof language === 'string' && language.length > 0)));
return {
...resolvedPost,
id: translation.id,
slug: `${resolvedPost.slug}.${translation.language}`,
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
language: translation.language,
updatedAt: translation.updatedAt,
publishedAt: translation.publishedAt ?? resolvedPost.publishedAt,
availableLanguages,
translationSourceSlug: resolvedPost.slug,
translationCanonicalLanguage: resolvedPost.language,
} as PostData;
}

View File

@@ -20,6 +20,7 @@ interface CompareSitemapToHtmlParams {
baseUrl: string;
htmlDir: string;
postTimestampChecks?: PostTimestampCheck[];
additionalExpectedPaths?: string[];
}
function normalizeUrlPath(urlPath: string): string {
@@ -123,6 +124,12 @@ export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams):
.map((value) => normalizeUrlPath(value)),
);
if (Array.isArray(params.additionalExpectedPaths)) {
for (const p of params.additionalExpectedPaths) {
expectedPathSet.add(normalizeUrlPath(p));
}
}
const { existingHtmlPathSet, zeroByteHtmlPathSet } = await collectHtmlIndexPaths(params.htmlDir);
const missingUrlPaths = Array.from(expectedPathSet)

View File

@@ -17,6 +17,7 @@ export interface MissingPathPlan {
requestedPageSlugs: Set<string>;
requestRootRoutes: boolean;
requiresFallbackSectionRender: boolean;
languagePlans: Map<string, MissingPathPlan>;
}
export interface TargetedValidationPlan {
@@ -58,35 +59,32 @@ function decodePathSegment(value: string): string {
}
}
export function planMissingValidationPaths(missingPaths: string[]): MissingPathPlan {
const requestedCategories = new Set<string>();
const requestedTags = new Set<string>();
const requestedYears = new Set<number>();
const requestedYearMonths = new Set<string>();
const requestedYearMonthDays = new Set<string>();
const requestedPostRoutes: RequestedPostRoute[] = [];
const requestedPageSlugs = new Set<string>();
let requestRootRoutes = false;
let requiresFallbackSectionRender = false;
for (const missingPath of missingPaths) {
const normalizedPath = normalizeUrlPath(missingPath);
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
requestRootRoutes = true;
continue;
function classifyPath(
normalizedPath: string,
requestedCategories: Set<string>,
requestedTags: Set<string>,
requestedYears: Set<number>,
requestedYearMonths: Set<string>,
requestedYearMonthDays: Set<string>,
requestedPostRoutes: RequestedPostRoute[],
requestedPageSlugs: Set<string>,
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean },
): void {
if (normalizedPath === '/' || /^\/(page\/\d+)$/.test(normalizedPath)) {
state.requestRootRoutes = true;
return;
}
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
if (categoryMatch) {
requestedCategories.add(decodePathSegment(categoryMatch[1]));
continue;
return;
}
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
if (tagMatch) {
requestedTags.add(decodePathSegment(tagMatch[1]));
continue;
return;
}
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
@@ -97,48 +95,131 @@ export function planMissingValidationPaths(missingPaths: string[]): MissingPathP
day: Number(singleMatch[3]),
slug: decodePathSegment(singleMatch[4]),
});
continue;
return;
}
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
if (yearMatch) {
requestedYears.add(Number(yearMatch[1]));
continue;
return;
}
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
if (monthMatch) {
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
continue;
return;
}
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
if (dayMatch) {
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
continue;
return;
}
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
if (pageMatch) {
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
return;
}
state.requiresFallbackSectionRender = true;
}
function createEmptyPlan(): {
requestedCategories: Set<string>;
requestedTags: Set<string>;
requestedYears: Set<number>;
requestedYearMonths: Set<string>;
requestedYearMonthDays: Set<string>;
requestedPostRoutes: RequestedPostRoute[];
requestedPageSlugs: Set<string>;
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean };
} {
return {
requestedCategories: new Set<string>(),
requestedTags: new Set<string>(),
requestedYears: new Set<number>(),
requestedYearMonths: new Set<string>(),
requestedYearMonthDays: new Set<string>(),
requestedPostRoutes: [],
requestedPageSlugs: new Set<string>(),
state: { requestRootRoutes: false, requiresFallbackSectionRender: false },
};
}
function finalizePlan(plan: ReturnType<typeof createEmptyPlan>, languagePlans: Map<string, MissingPathPlan>): MissingPathPlan {
return {
requestedCategories: plan.requestedCategories,
requestedTags: plan.requestedTags,
requestedYears: plan.requestedYears,
requestedYearMonths: plan.requestedYearMonths,
requestedYearMonthDays: plan.requestedYearMonthDays,
requestedPostRoutes: plan.requestedPostRoutes,
requestedPageSlugs: plan.requestedPageSlugs,
requestRootRoutes: plan.state.requestRootRoutes,
requiresFallbackSectionRender: plan.state.requiresFallbackSectionRender,
languagePlans,
};
}
export function planMissingValidationPaths(missingPaths: string[], additionalLanguages?: string[]): MissingPathPlan {
const mainPlan = createEmptyPlan();
const langPlanMap = new Map<string, ReturnType<typeof createEmptyPlan>>();
const knownLanguages = new Set((additionalLanguages ?? []).map((l) => l.trim().toLowerCase()).filter((l) => l.length > 0));
for (const missingPath of missingPaths) {
const normalizedPath = normalizeUrlPath(missingPath);
// Check for language prefix
const langPrefixMatch = normalizedPath.match(/^\/([a-z]{2,3})(?:(\/.+))?$/);
if (langPrefixMatch && knownLanguages.has(langPrefixMatch[1])) {
const lang = langPrefixMatch[1];
const strippedPath = langPrefixMatch[2] ? normalizeUrlPath(langPrefixMatch[2]) : '/';
if (!langPlanMap.has(lang)) {
langPlanMap.set(lang, createEmptyPlan());
}
const lp = langPlanMap.get(lang)!;
classifyPath(
strippedPath,
lp.requestedCategories,
lp.requestedTags,
lp.requestedYears,
lp.requestedYearMonths,
lp.requestedYearMonthDays,
lp.requestedPostRoutes,
lp.requestedPageSlugs,
lp.state,
);
if (lp.state.requiresFallbackSectionRender) {
mainPlan.state.requiresFallbackSectionRender = true;
break;
}
continue;
}
requiresFallbackSectionRender = true;
break;
classifyPath(
normalizedPath,
mainPlan.requestedCategories,
mainPlan.requestedTags,
mainPlan.requestedYears,
mainPlan.requestedYearMonths,
mainPlan.requestedYearMonthDays,
mainPlan.requestedPostRoutes,
mainPlan.requestedPageSlugs,
mainPlan.state,
);
if (mainPlan.state.requiresFallbackSectionRender) {
break;
}
}
return {
requestedCategories,
requestedTags,
requestedYears,
requestedYearMonths,
requestedYearMonthDays,
requestedPostRoutes,
requestedPageSlugs,
requestRootRoutes,
requiresFallbackSectionRender,
};
const languagePlans = new Map<string, MissingPathPlan>();
for (const [lang, lp] of langPlanMap) {
languagePlans.set(lang, finalizePlan(lp, new Map()));
}
return finalizePlan(mainPlan, languagePlans);
}
interface BuildTargetedValidationPlanParams {

View File

@@ -167,12 +167,14 @@ export function createBlogTools(deps: BlogToolDeps) {
query: z.string().describe('The search query text to find in posts'),
category: z.string().optional().describe('Optional category to filter by'),
tags: z.array(z.string()).optional().describe('Optional array of tags to filter by (all must match)'),
language: z.string().optional().describe('Require posts that are available in this language'),
missingTranslationLanguage: z.string().optional().describe('Require posts that are missing this translation language'),
year: z.number().optional().describe('Filter to posts created in this year'),
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
limit: z.number().optional().describe('Maximum number of results to return (default: 10)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
execute: async ({ query, category, tags, year, month, limit: lim, offset: off }) => {
execute: async ({ query, category, tags, language, missingTranslationLanguage, year, month, limit: lim, offset: off }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
@@ -180,6 +182,8 @@ export function createBlogTools(deps: BlogToolDeps) {
const filter: PostFilter = {};
if (category) filter.categories = [category];
if (tags && tags.length > 0) filter.tags = tags;
if (language) filter.language = language;
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
if (year !== undefined) filter.year = year;
if (month !== undefined && year !== undefined) filter.month = month;
@@ -194,6 +198,7 @@ export function createBlogTools(deps: BlogToolDeps) {
id: p.id, title: p.title, slug: p.slug,
excerpt: p.excerpt, status: p.status,
categories: p.categories, tags: p.tags,
availableLanguages: p.availableLanguages,
createdAt: p.createdAt, updatedAt: p.updatedAt,
})),
postEngine,
@@ -232,6 +237,7 @@ export function createBlogTools(deps: BlogToolDeps) {
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
@@ -260,6 +266,7 @@ export function createBlogTools(deps: BlogToolDeps) {
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
availableLanguages: post.availableLanguages,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
@@ -275,12 +282,14 @@ export function createBlogTools(deps: BlogToolDeps) {
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'),
language: z.string().optional().describe('Require posts that are available in this language'),
missingTranslationLanguage: z.string().optional().describe('Require posts that are missing this translation language'),
year: z.number().optional().describe('Filter to posts created in this year'),
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
execute: async ({ status, category, tags, year, month, limit: lim, offset: off }) => {
execute: async ({ status, category, tags, language, missingTranslationLanguage, year, month, limit: lim, offset: off }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
@@ -289,6 +298,8 @@ export function createBlogTools(deps: BlogToolDeps) {
if (status) filter.status = status;
if (tags) filter.tags = tags;
if (category) filter.categories = [category];
if (language) filter.language = language;
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
if (year !== undefined) filter.year = year;
if (month !== undefined && year !== undefined) filter.month = month;
@@ -317,7 +328,7 @@ export function createBlogTools(deps: BlogToolDeps) {
pageItems.map(p => ({
id: p.id, title: p.title, slug: p.slug,
status: p.status, categories: p.categories,
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
tags: p.tags, availableLanguages: p.availableLanguages, createdAt: p.createdAt, updatedAt: p.updatedAt,
})),
postEngine,
);

View File

@@ -0,0 +1,32 @@
/**
* Retry helper with exponential backoff for AI API calls.
*
* Delays: baseDelayMs * 2^attempt (e.g. 5s, 10s, 20s for base=5000).
*/
export interface RetryOptions {
/** Maximum number of retries after the initial attempt (default: 3). */
maxRetries?: number;
/** Base delay in ms before the first retry (default: 5000). Doubles each retry. */
baseDelayMs?: number;
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export async function retryWithBackoff<T extends { success: boolean }>(
fn: () => Promise<T>,
options?: RetryOptions,
): Promise<T> {
const maxRetries = options?.maxRetries ?? 3;
const baseDelayMs = options?.baseDelayMs ?? 5000;
let result = await fn();
for (let attempt = 0; attempt < maxRetries && !result.success; attempt++) {
const delay = baseDelayMs * Math.pow(2, attempt);
await sleep(delay);
result = await fn();
}
return result;
}

View File

@@ -46,6 +46,33 @@ export interface PostAnalysisResult {
error?: string;
}
export interface PostTranslationResult {
success: boolean;
translation?: Awaited<ReturnType<PostEngine['upsertPostTranslation']>>;
error?: string;
warning?: string;
}
export interface MediaTranslationResult {
success: boolean;
translation?: Awaited<ReturnType<MediaEngine['upsertMediaTranslation']>>;
error?: string;
}
export function normalizeTranslatedMarkdownBody(content: string, sourceContent: string): string {
const normalizedContent = content.trim();
if (!normalizedContent) {
return '';
}
const leadingLabelPattern = /^(content|inhalt|contenu|contenuto|contenido):\s*\n\s*\n/i;
if (!leadingLabelPattern.test(normalizedContent) || leadingLabelPattern.test(sourceContent.trim())) {
return normalizedContent;
}
return normalizedContent.replace(leadingLabelPattern, '');
}
// ---------------------------------------------------------------------------
// OneShotTasks
// ---------------------------------------------------------------------------
@@ -442,4 +469,271 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
return { success: false, error: (error as Error).message };
}
}
async translatePost(postId: string, targetLanguage: string, options?: { autoPublish?: boolean }): Promise<PostTranslationResult> {
if (!this.postEngine) {
return { success: false, error: 'Post engine not available' };
}
const post = await this.postEngine.getPost(postId);
if (!post) {
return { success: false, error: 'Post not found' };
}
if (!post.content || post.content.trim().length === 0) {
return { success: false, error: 'Post has no content to translate' };
}
let modelId = await this.chatEngine.getSetting('chat_title_model');
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
modelId = this.providers.getOpencodeKey()
? 'claude-sonnet-4-5'
: this.providers.getMistralKey()
? 'mistral-large-latest'
: null;
}
if (this.providers.isOfflineMode()) {
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|| this.providers.getFirstKnownLocalModelId();
if (offlineModel) {
modelId = offlineModel;
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' };
}
}
if (!modelId) {
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
}
// Auto-detect source language if not explicitly set on the post
let sourceLanguage = post.language || '';
if (!sourceLanguage) {
const detection = await this.detectPostLanguage(post.title, post.content || '');
if (detection.success && detection.language) {
sourceLanguage = detection.language;
await this.postEngine.updatePost(postId, { language: sourceLanguage });
} else {
sourceLanguage = 'en';
}
}
const metadataSystemPrompt = `You translate blog post metadata. Return ONLY valid JSON with keys title and excerpt. Do not add commentary. Do not invent or add any text that is not present in the source. Only translate the given text. Translate from ${sourceLanguage} to ${targetLanguage}.`;
const metadataUserPrompt = [
`Title: ${post.title}`,
`Excerpt: ${post.excerpt || ''}`,
].join('\n\n');
const contentSystemPrompt = `You translate blog post Markdown bodies. Return ONLY the translated Markdown body, with no JSON envelope and no commentary. Preserve Markdown structure. Leave text inside fenced code blocks untranslated. Keep markdown link text and URLs unchanged (e.g. [link text](URL) stays as-is). The body may contain mixed languages — always translate the surrounding prose from ${sourceLanguage} to ${targetLanguage}, even if some parts (like link text) are already in the target language. Do not invent, add, or generate any text that is not present in the source. Only translate the exact text provided. If the content contains only macro calls, shortcodes, or other non-translatable tokens, return them unchanged. If the source body is empty, return an empty string.`;
const contentUserPrompt = post.content;
try {
const model = this.providers.resolveModel(modelId);
const { text: metadataText } = await generateText({
model,
system: metadataSystemPrompt,
prompt: metadataUserPrompt,
maxOutputTokens: 500,
maxRetries: 2,
});
const { text: translatedContent } = await generateText({
model,
system: contentSystemPrompt,
prompt: contentUserPrompt,
maxOutputTokens: 4000,
maxRetries: 2,
});
let parsed: { title?: string; excerpt?: string } | null = null;
const jsonMatch = metadataText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
parsed = JSON.parse(jsonMatch[0]);
} catch {
parsed = null;
}
}
const normalizedTranslatedContent = normalizeTranslatedMarkdownBody(translatedContent || '', post.content);
// Collect warnings for partial failures
const warnings: string[] = [];
if (!parsed) {
warnings.push('metadata JSON parsing failed, title/excerpt kept as original');
}
if (normalizedTranslatedContent.trim() === post.content.trim()) {
warnings.push('translated content is identical to source');
}
const translation = await this.postEngine.upsertPostTranslation(postId, targetLanguage, {
title: parsed?.title || post.title,
excerpt: parsed?.excerpt || post.excerpt || undefined,
content: normalizedTranslatedContent,
status: options?.autoPublish ? 'published' : undefined,
});
return {
success: true,
translation,
warning: warnings.length > 0 ? warnings.join('; ') : undefined,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Detect the language of media metadata (title, alt, caption).
* Uses the configured title model (lightweight, text-only).
*/
async detectMediaLanguage(
title: string,
alt: string,
caption: string,
): Promise<LanguageDetectionResult> {
const combined = [title, alt, caption].filter(Boolean).join('\n');
if (!combined.trim()) {
return { success: false, error: 'No metadata text provided for language detection' };
}
let modelId = await this.chatEngine.getSetting('chat_title_model');
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
modelId = this.providers.getOpencodeKey()
? 'claude-sonnet-4-5'
: this.providers.getMistralKey()
? 'mistral-large-latest'
: null;
}
if (this.providers.isOfflineMode()) {
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|| this.providers.getFirstKnownLocalModelId();
if (offlineModel) {
modelId = offlineModel;
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' };
}
}
if (!modelId) {
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
}
const supportedLanguages = ['en', 'de', 'fr', 'it', 'es'];
const systemPrompt = `You are a language detection assistant. Given image metadata (title, alt text, caption), determine the language. Respond with ONLY a JSON object: { "language": "<code>" } where <code> is one of: ${supportedLanguages.join(', ')}. If the language is not in the list, pick the closest match. No other text.`;
const userPrompt = `Title: ${title}\nAlt: ${alt}\nCaption: ${caption}`;
try {
const model = this.providers.resolveModel(modelId);
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: 50,
maxRetries: 2,
});
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
const result = JSON.parse(jsonMatch[0]);
const detected = (result.language || '').toLowerCase().trim();
if (!supportedLanguages.includes(detected)) {
return { success: false, error: `Unsupported language detected: ${detected}` };
}
return { success: true, language: detected };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Translate media metadata (title, alt, caption) into a target language.
* Persists the result as a media translation via MediaEngine.
*/
async translateMediaMetadata(
mediaId: string,
targetLanguage: string,
): Promise<MediaTranslationResult> {
const mediaItem = await this.mediaEngine.getMedia(mediaId);
if (!mediaItem) {
return { success: false, error: 'Media item not found' };
}
const hasMetadata = mediaItem.title || mediaItem.alt || mediaItem.caption;
if (!hasMetadata) {
return { success: false, error: 'Media item has no metadata to translate' };
}
let modelId = await this.chatEngine.getSetting('chat_title_model');
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
modelId = this.providers.getOpencodeKey()
? 'claude-sonnet-4-5'
: this.providers.getMistralKey()
? 'mistral-large-latest'
: null;
}
if (this.providers.isOfflineMode()) {
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|| this.providers.getFirstKnownLocalModelId();
if (offlineModel) {
modelId = offlineModel;
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' };
}
}
if (!modelId) {
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
}
// Auto-detect source language if not explicitly set on the media
let sourceLanguage = mediaItem.language || '';
if (!sourceLanguage) {
const detection = await this.detectMediaLanguage(
mediaItem.title || '',
mediaItem.alt || '',
mediaItem.caption || '',
);
if (detection.success && detection.language) {
sourceLanguage = detection.language;
await this.mediaEngine.updateMedia(mediaId, { language: sourceLanguage });
} else {
sourceLanguage = 'en';
}
}
const systemPrompt = `You translate image metadata. Return ONLY valid JSON with keys title, alt, caption. Do not add commentary. Translate from ${sourceLanguage} to ${targetLanguage}. If a field is null or empty, return it as null.`;
const userPrompt = [
`Title: ${mediaItem.title || ''}`,
`Alt: ${mediaItem.alt || ''}`,
`Caption: ${mediaItem.caption || ''}`,
].join('\n');
try {
const model = this.providers.resolveModel(modelId);
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: 300,
maxRetries: 2,
});
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
const parsed = JSON.parse(jsonMatch[0]);
const translation = await this.mediaEngine.upsertMediaTranslation(mediaId, targetLanguage, {
title: parsed.title || undefined,
alt: parsed.alt || undefined,
caption: parsed.caption || undefined,
});
return { success: true, translation };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
}

View File

@@ -143,3 +143,8 @@ main { display: grid; gap: 1rem; }
.preview-pagination .spacer { flex: 1; }
.not-found { display: grid; place-items: center; min-height: 48vh; }
.not-found article { max-width: 32rem; text-align: center; }
.language-switcher { position: fixed; right: .75rem; top: 1.5rem; display: flex; flex-direction: column; gap: .1rem; z-index: 100; }
.language-switcher-badge { display: block; padding: .05rem .1rem; font-size: .85rem; line-height: 1.1; text-decoration: none; border: 1px solid transparent; border-radius: .15rem; cursor: pointer; opacity: .7; transition: opacity .15s ease-in-out; }
.language-switcher-badge:hover,
.language-switcher-badge:focus-visible { opacity: 1; border-color: var(--pico-color, var(--color)); }
.language-switcher-badge-current { opacity: 1; border-color: var(--pico-primary, var(--primary)); }

View File

@@ -9,6 +9,8 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
return;
}
const languagePrefix = document.documentElement.getAttribute('data-language-prefix') || '';
const labels = {
loading: panel.getAttribute('data-i18n-loading') || 'Loading calendar…',
error: panel.getAttribute('data-i18n-error') || 'Calendar data could not be loaded.',
@@ -70,7 +72,7 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
if (!pathname) {
return;
}
window.location.assign(pathname);
window.location.assign(languagePrefix + pathname);
}
function parseInitialYearMonth() {
@@ -86,7 +88,10 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
: null;
if (!Number.isInteger(selectedYear) || !Number.isInteger(selectedMonth)) {
const pathname = window.location.pathname || '';
const rawPathname = window.location.pathname || '';
const pathname = languagePrefix && rawPathname.startsWith(languagePrefix + '/')
? rawPathname.slice(languagePrefix.length)
: rawPathname;
const parts = pathname.split('/').filter(Boolean);
const pathYear = Number(parts[0]);
const pathMonth = Number(parts[1]);
@@ -104,7 +109,7 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
}
async function loadCalendarData() {
const response = await fetch('/calendar.json', { cache: 'no-store' });
const response = await fetch(languagePrefix + '/calendar.json', { cache: 'no-store' });
if (!response.ok) {
throw new Error('calendar.json request failed');
}

View File

@@ -174,6 +174,7 @@ export const TAG_CLOUD_RUNTIME_JS = String.raw`(function () {
return;
}
const langPrefix = document.documentElement.getAttribute('data-language-prefix') || '';
const rawWords = container.getAttribute('data-tag-cloud-words');
const words = parseWords(rawWords);
if (words.length === 0) {
@@ -246,7 +247,7 @@ export const TAG_CLOUD_RUNTIME_JS = String.raw`(function () {
textNode.addEventListener('click', () => {
if (word && typeof word.url === 'string' && word.url.length > 0) {
window.location.assign(word.url);
window.location.assign(langPrefix + word.url);
}
});

View File

@@ -21,6 +21,7 @@ export {
stemQuery,
prepareForFTS,
getSupportedLanguages,
isoToStemmerLanguage,
type SupportedLanguage,
} from './stemmer';
export {

View File

@@ -105,6 +105,9 @@ const METHOD_NAME_MAP: Record<string, string> = {
'posts.getAll': 'getAllPosts',
'posts.getByStatus': 'getPostsByStatus',
'posts.publish': 'publishPost',
'posts.getTranslation': 'getPostTranslation',
'posts.getTranslations': 'getPostTranslations',
'posts.publishTranslation': 'publishPostTranslation',
'posts.discard': 'discardChanges',
'posts.hasPublishedVersion': 'hasPublishedVersion',
'posts.rebuildFromFiles': 'rebuildDatabaseFromFiles',

View File

@@ -15,6 +15,8 @@ export interface PostFileData {
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
doNotTranslate?: boolean;
templateSlug?: string;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
@@ -30,6 +32,8 @@ interface PostFileMetadata {
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
doNotTranslate?: boolean;
templateSlug?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
@@ -65,6 +69,8 @@ export async function readPostFile(filePath: string): Promise<PostFileData | nul
status: metadata.status,
author: metadata.author,
language: metadata.language || undefined,
doNotTranslate: metadata.doNotTranslate === true,
templateSlug: metadata.templateSlug || undefined,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,

View File

@@ -0,0 +1,46 @@
import * as fs from 'fs/promises';
import matter from 'gray-matter';
export interface PostTranslationFileData {
translationFor: string;
language: string;
title: string;
excerpt?: string;
content: string;
}
interface PostTranslationFileMetadata {
translationFor?: string;
language?: string;
title?: string;
excerpt?: string;
}
export async function readPostTranslationFile(filePath: string): Promise<PostTranslationFileData | null> {
try {
try {
await fs.access(filePath);
} catch {
return null;
}
const fileContent = await fs.readFile(filePath, 'utf-8');
const { data, content } = matter(fileContent);
const metadata = data as PostTranslationFileMetadata;
if (!metadata.translationFor || !metadata.language || !metadata.title) {
return null;
}
return {
translationFor: metadata.translationFor,
language: metadata.language,
title: metadata.title,
excerpt: metadata.excerpt || undefined,
content,
};
} catch (error) {
console.error(`Failed to parse post translation file: ${filePath}`, error);
return null;
}
}

View File

@@ -195,6 +195,13 @@ import json as _json
import inspect as _inspect
_macro_ctx = _json.loads(__bds_macro_context_json)
_bds_translations = _macro_ctx.get('env', {}).get('translations', {})
def T(key, **kwargs):
val = _bds_translations.get(key, key)
if kwargs:
for k, v in kwargs.items():
val = val.replace('{' + k + '}', str(v))
return val
_macro_ep = __bds_macro_entrypoint
_macro_fn = globals().get(_macro_ep)
if _macro_fn is None or not callable(_macro_fn):

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
<body>
<main>

View File

@@ -8,8 +8,12 @@
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
{% assign feed_prefix = language_prefix | default: '' %}
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
{% for alternate_link in alternate_links %}
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
{% endfor %}
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>

View File

@@ -0,0 +1,18 @@
{% if blog_languages.size > 1 %}
<nav class="language-switcher" aria-label="{{ 'render.languageSwitcher.ariaLabel' | i18n: language }}">
{% for lang in blog_languages %}
{% if lang.is_current %}
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
{% else %}
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
{% endif %}
{% endfor %}
</nav>
<script>
(function(){
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
}());
</script>
{% endif %}

View File

@@ -1,8 +1,9 @@
<!doctype html>
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
{% if archive_context %}
{% if show_archive_range_heading and min_date and max_date %}
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
@@ -44,7 +45,7 @@
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
</div>
{% endfor %}
</div>
@@ -59,7 +60,7 @@
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
</div>
{% endfor %}
{% endif %}

View File

@@ -1,8 +1,9 @@
<!doctype html>
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
<h1>{{ post.title }}</h1>
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
{% if post_categories.size > 0 or post_tags.size > 0 %}
@@ -17,7 +18,7 @@
</div>
{% endif %}
<article class="single-post" data-template="single-post">
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}</div>
</article>
{% if backlinks.size > 0 %}
<div class="single-post-backlinks" aria-label="{{ 'render.backlinks.ariaLabel' | i18n: language }}">