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

@@ -20,7 +20,12 @@ This document provides context and best practices for GitHub Copilot when workin
- UI you implement has to be tied to functionality
- you must use drizzle to generate migrations and snapshots
- we use an sqlite database. use sqlite semantics in snapshots and other artifacts
- oh MacOS we use native menus and you have to hook them into the intercept for new menu items
- on MacOS we use native menus and you have to hook them into the intercept for new menu items
- there are two areas of localization, you sometimes need both (menus for example)
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
- HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripots
---

394
API.md
View File

@@ -1,6 +1,6 @@
# API Documentation
Contract version: 1.13.0
Contract version: 1.15.0
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
@@ -331,6 +331,9 @@ None # or
- [posts.getAll](#postsgetall)
- [posts.getByStatus](#postsgetbystatus)
- [posts.publish](#postspublish)
- [posts.getTranslation](#postsgettranslation)
- [posts.getTranslations](#postsgettranslations)
- [posts.publishTranslation](#postspublishtranslation)
- [posts.discard](#postsdiscard)
- [posts.hasPublishedVersion](#postshaspublishedversion)
- [posts.rebuildFromFiles](#postsrebuildfromfiles)
@@ -386,7 +389,8 @@ result = await bds.posts.create(data={})
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
```
@@ -430,7 +434,8 @@ None # or
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
```
@@ -498,7 +503,8 @@ None # or
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
```
@@ -541,13 +547,14 @@ None # or
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
```
### posts.getPreviewUrl
Get preview URL for post.
Get preview URL for post. options may include draft=true and lang=<language-code>.
**Parameters**
@@ -635,7 +642,8 @@ result = await bds.posts.get_by_status(status='status')
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
]
```
@@ -679,7 +687,133 @@ None # or
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
```
### posts.getTranslation
Get a single translation for a post by language.
**Parameters**
- postId (str, required)
- language (str, required)
**Response specification**
- Return type: `PostTranslationData | null`
- Nullability: Returns `None` when no matching value exists.
- Data structures: `PostTranslationData`
**Example call**
```python
from bds_api import bds
result = await bds.posts.get_translation(post_id='post-1', language='language')
```
**Example response**
```python
None # or
{
'id': 'value',
'projectId': 'value',
'translationFor': 'value',
'language': 'value',
'title': 'value',
'excerpt': 'value',
'content': 'value',
'status': 'draft',
'createdAt': 'value',
'updatedAt': 'value',
'publishedAt': 'value',
'filePath': 'value'
}
```
### posts.getTranslations
Get all translations for a post.
**Parameters**
- postId (str, required)
**Response specification**
- Return type: `PostTranslationData[]`
- Data structures: `PostTranslationData`
**Example call**
```python
from bds_api import bds
result = await bds.posts.get_translations(post_id='post-1')
```
**Example response**
```python
[
{
'id': 'value',
'projectId': 'value',
'translationFor': 'value',
'language': 'value',
'title': 'value',
'excerpt': 'value',
'content': 'value',
'status': 'draft',
'createdAt': 'value',
'updatedAt': 'value',
'publishedAt': 'value',
'filePath': 'value'
}
]
```
### posts.publishTranslation
Publish a specific translation of a post.
**Parameters**
- postId (str, required)
- language (str, required)
**Response specification**
- Return type: `PostTranslationData | null`
- Nullability: Returns `None` when no matching value exists.
- Data structures: `PostTranslationData`
**Example call**
```python
from bds_api import bds
result = await bds.posts.publish_translation(post_id='post-1', language='language')
```
**Example response**
```python
None # or
{
'id': 'value',
'projectId': 'value',
'translationFor': 'value',
'language': 'value',
'title': 'value',
'excerpt': 'value',
'content': 'value',
'status': 'draft',
'createdAt': 'value',
'updatedAt': 'value',
'publishedAt': 'value',
'filePath': 'value'
}
```
@@ -722,7 +856,8 @@ None # or
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
```
@@ -828,7 +963,7 @@ result = await bds.posts.search(query='search phrase')
### posts.filter
Filter posts by criteria.
Filter posts by criteria, including optional language and missingTranslationLanguage filters.
**Parameters**
@@ -864,7 +999,8 @@ result = await bds.posts.filter(filter={})
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
]
```
@@ -1057,7 +1193,8 @@ result = await bds.posts.get_links_to(id='id-1')
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
]
```
@@ -1100,7 +1237,8 @@ result = await bds.posts.get_linked_by(id='id-1')
'updatedAt': 'value',
'publishedAt': 'value',
'tags': 'value',
'categories': 'value'
'categories': 'value',
'availableLanguages': 'value'
}
]
```
@@ -1206,6 +1344,10 @@ result = await bds.posts.generate_unique_slug(title='title')
- [media.getByYearMonth](#mediagetbyyearmonth)
- [media.getTags](#mediagettags)
- [media.getTagsWithCounts](#mediagettagswithcounts)
- [media.getTranslation](#mediagettranslation)
- [media.getTranslations](#mediagettranslations)
- [media.upsertTranslation](#mediaupserttranslation)
- [media.deleteTranslation](#mediadeletetranslation)
### media.import
@@ -1777,6 +1919,111 @@ result = await bds.media.get_tags_with_counts()
[]
```
### media.getTranslation
Get a single translation for a media item by language.
**Parameters**
- mediaId (str, required)
- language (str, required)
**Response specification**
- Return type: `MediaTranslationData | null`
- Nullability: Returns `None` when no matching value exists.
**Example call**
```python
from bds_api import bds
result = await bds.media.get_translation(media_id='media-1', language='language')
```
**Example response**
```python
None # or dict-like object when found
```
### media.getTranslations
Get all translations for a media item.
**Parameters**
- mediaId (str, required)
**Response specification**
- Return type: `MediaTranslationData[]`
**Example call**
```python
from bds_api import bds
result = await bds.media.get_translations(media_id='media-1')
```
**Example response**
```python
[]
```
### media.upsertTranslation
Create or update a media translation for a specific language.
**Parameters**
- mediaId (str, required)
- language (str, required)
- data (dict, required)
**Response specification**
- Return type: `MediaTranslationData`
**Example call**
```python
from bds_api import bds
result = await bds.media.upsert_translation(media_id='media-1', language='language', data={})
```
**Example response**
```python
{}
```
### media.deleteTranslation
Delete a media translation by language.
**Parameters**
- mediaId (str, required)
- language (str, required)
**Response specification**
- Return type: `boolean`
**Example call**
```python
from bds_api import bds
result = await bds.media.delete_translation(media_id='media-1', language='language')
```
**Example response**
```python
True
```
[↑ Back to Table of contents](#table-of-contents)
## scripts
@@ -3514,6 +3761,9 @@ result = await bds.tags.sync_from_posts()
- [chat.analyzeMediaImage](#chatanalyzemediaimage)
- [chat.detectPostLanguage](#chatdetectpostlanguage)
- [chat.analyzePost](#chatanalyzepost)
- [chat.translatePost](#chattranslatepost)
- [chat.detectMediaLanguage](#chatdetectmedialanguage)
- [chat.translateMediaMetadata](#chattranslatemediametadata)
### chat.analyzeMediaImage
@@ -3607,6 +3857,90 @@ result = await bds.chat.analyze_post(post_id='post-1')
}
```
### chat.translatePost
Translate a post into a target language and save it as a translation draft.
**Parameters**
- postId (str, required)
- targetLanguage (str, required)
**Response specification**
- Return type: `PostTranslationResult`
- Data structures: `PostTranslationResult`
**Example call**
```python
from bds_api import bds
result = await bds.chat.translate_post(post_id='post-1', target_language='target_language')
```
**Example response**
```python
{
'success': False,
'translation': None,
'error': 'value'
}
```
### chat.detectMediaLanguage
Detect the language of media metadata from its title, alt text, and caption.
**Parameters**
- title (str, required)
- alt (str, required)
- caption (str, required)
**Response specification**
- Return type: `{ success: boolean; language?: string; error?: string }`
**Example call**
```python
from bds_api import bds
result = await bds.chat.detect_media_language(title='title', alt='alt', caption='caption')
```
**Example response**
```python
{}
```
### chat.translateMediaMetadata
Translate media metadata (title, alt, caption) into a target language using AI.
**Parameters**
- mediaId (str, required)
- targetLanguage (str, required)
**Response specification**
- Return type: `MediaTranslationResult`
**Example call**
```python
from bds_api import bds
result = await bds.chat.translate_media_metadata(media_id='media-1', target_language='target_language')
```
**Example response**
```python
{}
```
[↑ Back to Table of contents](#table-of-contents)
## sync
@@ -4200,6 +4534,7 @@ Canonical post object used across editor and generation flows.
- publishedAt (`string`, optional): Publication timestamp for published posts.
- tags (`string[]`, required): List of tag names.
- categories (`string[]`, required): List of category names.
- availableLanguages (`string[]`, required): Canonical language plus all available translation language codes for this post.
[↑ Back to Table of contents](#table-of-contents)
@@ -4423,6 +4758,39 @@ Result from AI post analysis containing suggested title, excerpt, and slug.
[↑ Back to Table of contents](#table-of-contents)
### PostTranslationData
Stored translation draft or published translation for a post.
**Fields**
- id (`string`, required): Translation identifier.
- projectId (`string`, required): Owning project identifier.
- translationFor (`string`, required): Source post identifier this translation belongs to.
- language (`string`, required): Target language code for the translation.
- title (`string`, required): Translated title.
- excerpt (`string`, optional): Translated excerpt.
- content (`string`, required): Translated Markdown content.
- status (`'draft' | 'published' | 'archived'`, required): Translation lifecycle state.
- createdAt (`string`, required): Creation timestamp.
- updatedAt (`string`, required): Last update timestamp.
- publishedAt (`string`, optional): Publish timestamp when the translation is published.
- filePath (`string`, required): Translation file path on disk.
[↑ Back to Table of contents](#table-of-contents)
### PostTranslationResult
Result from AI post translation containing the saved translation draft.
**Fields**
- success (`boolean`, required): Whether the translation succeeded.
- translation (`PostTranslationData`, optional): Saved translation draft when successful.
- error (`string`, optional): Error message when translation failed.
[↑ Back to Table of contents](#table-of-contents)
### SimilarPost
A post with its semantic similarity score relative to a reference post.

553
TODO.md
View File

@@ -1,5 +1,7 @@
# bDS — Remaining Feature Work
<!-- markdownlint-disable MD024 -->
This document covers the features described in VISION.md that are not yet
implemented. Each section is a self-contained plan that can be picked up
independently.
@@ -10,121 +12,342 @@ independently.
### Goal
Posts have a language attribute. The AI importing agent detects post language
and can auto-translate posts. Posts link to their translations so the
publishing pipeline can generate multilingual output.
Posts keep their canonical metadata in the main `posts` table. Translations are
stored separately so translated variants cannot drift into full independent
posts with their own unrelated metadata. The system must expose translation
availability everywhere posts are consumed.
### Current State
- Posts have no `language` field.
- No translation relationship tracking.
- No language detection during import.
- No AI translation tools.
- The `excerpt` field already exists and can serve as the summary field
mentioned in the vision.
- `analyzeMediaImage()` in `OpenCodeManager` already demonstrates the pattern
for single-shot AI analysis with language parameters.
- Project-level `mainLanguage` exists in `MetaEngine`.
- No translation storage model yet.
- No translation relationship tracking yet.
- No translation-aware post metadata such as `availableLanguages` yet.
- No language/missing-language filtering in post query APIs yet.
- AI post language detection already exists via `chat:detectPostLanguage`.
- AI post analysis already exists via `chat:analyzePost` and already suggests
title, excerpt, and slug.
- Project-level `mainLanguage` already exists.
### Implementation Plan
#### 1.1 Database Schema
Extend the `posts` table:
Add a dedicated translations table instead of storing translations as normal
posts.
| Column | Type | Notes |
|-----------------|------|-------------------------------------------------|
| language | text | ISO code (`en`, `de`, etc.), defaults to project `mainLanguage` |
| translationOfId | text | FK to posts.id — the original post this is a translation of |
Each translation row should contain only:
No separate junction table needed. A translated post is simply a post with
`translationOfId` pointing at its source. This keeps the model simple: each
post belongs to exactly one language and optionally references one original.
- its own ID
- `translationFor` referencing the source post ID
- `language`
- `title`
- `excerpt`
- `content` for draft translations only
- normal timestamps/status fields needed for lifecycle management
Published translations follow the same rule as published posts: body content is
not stored in the database and is read from the filesystem.
#### 1.2 YAML Frontmatter
Extend `postFileUtils.ts` to read/write:
Store translation files separately but in the same general markdown + YAML
frontmatter style as posts, with only the supported translation fields:
```yaml
language: de
translationOf: <original-post-id>
translationFor: <original-post-id>
language: fr
title: ...
excerpt: ...
```
On `readPostFile()`, parse these fields. On `writePostFile()`, include them
when present.
Draft translation files include markdown body content. Published translation
files keep content only in the file, not in the database. File naming is based
on the source post slug plus the language code, for example:
- `this-slug.md` for the source post
- `this-slug.fr.md` for the French translation
#### 1.3 PostEngine Extensions
Add methods:
Add translation-aware storage and lookup methods instead of treating
translations as regular posts.
- `getTranslations(postId)` — find all posts where `translationOfId === postId`.
- `getOriginal(postId)` — if the post has `translationOfId`, return that post.
- `createTranslation(originalPostId, targetLanguage, content)` — create a new
post linked to the original with the target language set.
- Create/read/update/publish translation records.
- Resolve translations for a post by source post ID.
- Prevent duplicate translations for the same `(translationFor, language)` pair.
- Keep source post metadata authoritative; translations only override the fields
they actually own.
- Keep `getPost(id)` and `getPostBySlug(slug)` canonical-only.
- Add explicit translation reads such as `getPostTranslation(postId, language)`
and `getPostTranslations(postId)` instead of overloading `getPost()` with an
optional language parameter.
- If callers need "best variant for language X", add a separate higher-level
resolver rather than changing the semantics of the base post APIs.
Modify `createPost()` and `updatePost()` to accept and persist the `language`
and `translationOfId` fields.
Post APIs should expose an `availableLanguages` meta field derived from the
translations table for every source post.
#### 1.4 AI Translation Tools in OpenCodeManager
Add three new methods following the `analyzeMediaImage()` pattern:
**`detectPostLanguage(postId)`**
- Read post content.
- Send to AI with prompt: "Detect the language of this text. Return a JSON
object with `language` (ISO 639-1 code) and `confidence` (0-1)."
- Return `{ language: string, confidence: number }`.
Add translation generation on top of the existing one-shot AI tooling.
**`translatePost(postId, targetLanguage)`**
- Read full post content + title + excerpt.
- Send to AI with prompt: "Translate this blog post to {language}. Return JSON
with `title`, `content` (markdown), and `excerpt`."
- Return translated fields without creating a post (caller decides).
**`generatePostSummary(postId)`**
- Read post content.
- Send to AI: "Write a 2-3 sentence summary of this blog post in
{post.language}. Return JSON with `excerpt`."
- Return `{ excerpt: string }`.
- Read the source post's full content plus title/excerpt.
- Return translated `title`, `excerpt`, and markdown `content`.
- Create or update a translation record/file from the returned data.
- After the post translation is persisted, cascade to linked media: for every
image linked to the source post that does not already have a translation for
`targetLanguage`, call `translateMediaMetadata(mediaId, targetLanguage)` (see
§2.5) to keep the post and its images in the same set of languages.
Register these as IPC handlers: `chat:detectPostLanguage`,
`chat:translatePost`, `chat:generatePostSummary`.
Language detection and excerpt suggestion already exist; this step is only
about translation-specific tooling.
#### 1.5 Import Pipeline Integration
In `ImportExecutionEngine`, after a post is imported and published:
Integrate with the existing import flow without redefining source posts as
translations.
1. Call `detectPostLanguage()` to set the `language` field.
2. If the detected language differs from the project's `mainLanguage`, queue
a translation task via `TaskManager`.
3. The translation task calls `translatePost()`, creates a new post via
`createTranslation()`, and publishes it.
2. Optionally queue translation generation for configured target languages.
3. Persist generated results as translation records/files linked via
`translationFor`.
This is optional and should be configurable per import definition (a checkbox
"Auto-detect language and translate" in `ImportAnalysisView`).
#### 1.6 UI — Translation Panel
#### 1.6 API Surface
Expose translation metadata consistently across all post consumers:
- Templates and Python scripts can read `post.meta.availableLanguages`.
- Internal AI tools can inspect available translation languages.
- MCP post APIs return the same `availableLanguages` data.
- Python post-query APIs support filtering by `language` and by missing
translation language, so callers can ask for posts available in French or
posts missing Spanish.
- The same language and missing-language filters must be available to internal
AI tools and MCP server queries.
#### 1.7 UI — Translation Panel
In the post editor metadata area, add a "Translations" section:
- Show current post language (dropdown to change).
- List existing translations with links (open in new tab).
- "Translate to..." button that opens a language picker, triggers AI
translation, and creates the linked post.
- If the post is itself a translation, show "Original: {title}" link.
- List existing translations by language.
- "Translate to..." creates or refreshes the separate translation record.
- Show which configured languages are still missing.
In the sidebar post list, optionally show a language badge per post.
#### 1.7 Publishing Pipeline
#### 1.8 Publishing Pipeline
In `PageRenderer` and `BlogGenerationEngine`:
- Add `hreflang` link tags to generated HTML when translations exist.
- Optionally generate a language switcher partial that templates can include.
- Sitemap should include `xhtml:link` entries for alternate language versions.
- Resolve alternate-language links from the translations table.
- Add `hreflang` metadata and language switcher data from DB-backed
translation availability.
- Include alternate language entries in sitemap generation.
---
## 2. Media Translation System
### Goal
Media files keep their canonical metadata in the main `media` table and main
sidecar. Translations are stored separately so localized metadata cannot drift
into independent media records. The binary asset remains shared; only the
language-specific metadata varies.
### Current State
- No media translation storage model yet.
- No translation relationship tracking for media yet.
- No translation-aware media metadata such as `availableLanguages` yet.
- No language/missing-language filtering in media query APIs yet.
- No explicit language tracking on canonical media metadata yet.
- AI media analysis already exists via `chat:analyzeMediaImage` and already
suggests title, alt text, and caption in a requested language.
- Main media metadata already uses DB + sidecar persistence.
### Implementation Plan
#### 2.1 Database Schema
Add a `language` column to the `media` table (optional text, ISO code such as
`'en'`, `'de'`). This records what language the canonical `title`, `alt`, and
`caption` are written in. When null, the project `mainLanguage` is assumed.
Persist the value in the canonical sidecar as well.
Add a dedicated media translations table instead of storing localized metadata
inside the canonical `media` row.
Each translation row should contain only:
- its own ID
- `translationFor` referencing the source media ID
- `language`
- `title`
- `alt`
- `caption`
- normal timestamps needed for lifecycle and sync
The binary media file remains the canonical original file. Translation rows do
not duplicate the asset itself.
#### 2.2 Sidecar Format
Keep the canonical sidecar for canonical metadata, and add language-specific
sidecars for translated metadata only.
Canonical sidecar stays with the original media file, for example:
- `image.jpg.meta`
Translated metadata sidecars use the source filename plus the language code,
for example:
- `image.jpg.fr.meta`
Translated sidecars should contain only the supported translation fields:
```yaml
translationFor: <original-media-id>
language: fr
title: ...
alt: ...
caption: ...
```
#### 2.3 MediaEngine Extensions
Add translation-aware storage and lookup methods instead of treating media
translations as separate media items.
- Create/read/update translation records and translated sidecars.
- Resolve translations for a media item by source media ID.
- Prevent duplicate translations for the same `(translationFor, language)` pair.
- Keep source media metadata authoritative; translations only override
`title`, `alt`, and `caption`.
- Keep `getMedia(id)` canonical-only.
- Add explicit translation reads such as `getMediaTranslation(mediaId,
language)` and `getMediaTranslations(mediaId)` instead of overloading
`getMedia()` with an optional language parameter.
- If callers need "best variant for language X", add a separate higher-level
resolver rather than changing the semantics of the base media APIs.
Media APIs should expose an `availableLanguages` meta field derived from the
translations table for every canonical media item.
#### 2.4 AI Translation Tools
Add media-metadata translation on top of the existing one-shot AI tooling.
**`translateMediaMetadata(mediaId, targetLanguage)`**
- Read the source media metadata plus image context needed for a faithful
translation.
- Determine source language from `media.language` (falling back to the
project `mainLanguage`).
- Return translated `title`, `alt`, and `caption`.
- Create or update a translation record/sidecar from the returned data.
**`detectMediaLanguage(mediaId)`**
- Read the canonical `title`, `alt`, and `caption` of a media item.
- Use the same lightweight title model and detection pattern as
`detectPostLanguage`.
- Return the detected ISO language code.
- Optionally persist the result to `media.language` if the caller requests it.
AI media analysis already exists; these steps are only about
language-detection and translation-specific tooling.
#### 2.5 Post-Triggered Media Translation Cascade
When a post is translated, all images linked to that post should be translated
automatically so rendered output never mixes languages.
**Trigger**: After `translatePost(postId, targetLanguage)` successfully
persists a post translation (§1.4), the system resolves all media linked to
the source post via the `postMedia` junction table.
**For each linked media item**:
1. Check whether the media already has a translation for `targetLanguage`
(via `getMediaTranslation(mediaId, targetLanguage)`).
2. If a translation already exists, skip — the image is already covered.
3. If no translation exists, call `translateMediaMetadata(mediaId,
targetLanguage)` (§2.4) to generate and persist the translated `title`,
`alt`, and `caption`.
**Design constraints**:
- The cascade is additive only — it never overwrites an existing media
translation. Users who independently translate an image via quick action
keep their version.
- Images can still be translated independently at any time through their own
quick action or the media translation panel (§2.7). The cascade merely
ensures coverage; it does not create a hard coupling.
- Failures on individual media translations should be logged but must not
block the post translation from succeeding. Report partial failures to the
UI so the user can retry individual images.
- The cascade runs after the post translation is committed, not inside the
same transaction, so a media-translation failure never rolls back post
work.
#### 2.6 Import And Sync Integration
Integrate with the existing media import and metadata sync flow without
creating translated duplicate media records.
1. Import the binary asset once as the canonical media item.
2. Optionally queue metadata translation generation for configured target
languages.
3. Persist generated results as translation records/sidecars linked via
`translationFor`.
4. Extend metadata diff/sync tooling so canonical and translated sidecars can
both be compared against the database safely.
#### 2.7 API Surface
Expose translation metadata consistently across all media consumers:
- Templates and Python scripts can read `media.meta.availableLanguages`.
- Internal AI tools can inspect available translation languages.
- MCP media APIs return the same `availableLanguages` data.
- Python media-query APIs support filtering by `language` and by missing
translation language, so callers can ask for media with French metadata or
media missing Spanish metadata.
- The same language and missing-language filters must be available to internal
AI tools and MCP server queries.
#### 2.8 UI — Translation Panel
In the media editor/details area, add a "Translations" section:
- Show the canonical media language as a dropdown (same UX as the post
language selector). Changing it updates `media.language`.
- Provide a "Detect Language" button that calls `detectMediaLanguage` and
updates the dropdown.
- List existing metadata translations by language.
- "Translate to..." creates or refreshes the separate translation record.
- Show which configured languages are still missing.
Media list/detail views can optionally show a language-availability badge.
#### 2.9 Rendering And Asset Use
When media metadata is consumed during rendering or editing:
- Resolve localized `title`, `alt`, and `caption` from the translations table
when a language-specific variant is requested.
- Fall back to canonical metadata when no translation exists.
- Keep URLs and binary asset references stable; only metadata changes by
language.
---
## 3. Drag-and-Drop Image Insertion
@@ -235,3 +458,205 @@ The same plugin can handle `paste` events with image files:
editor state after drop.
- Test edge cases: non-image files, failed imports, multiple simultaneous
drops.
---
## 4. Multi-Language Blog Rendering (Phase 2)
### Goal
The generated blog is fully navigable in each activated language. Every
language gets its own route subtree (`/en/`, `/de/`, …), its own feeds, and
its own sitemap entries. Media assets are shared; only HTML differs. The
preview server must serve the same language-prefixed routes so the user can
verify output before uploading.
### Current State
- Post and media translation schemas, CRUD, AI translation, and validation
already exist (§1, §2).
- `PageRenderer` already accepts `preferredLanguage` and resolves translations
via `resolveRenderablePost()` and `getMediaTranslation()`.
- `BlogGenerationEngine` builds translation variants with `.lang` slug
suffixes but writes everything to a flat `html/` directory — no
language-prefixed subtrees.
- `PreviewServer` supports a `?lang=` query parameter but has no
language-prefixed routes.
- `ProjectMetadata` has `mainLanguage` but no `blogLanguages` list.
- No `doNotTranslate` flag on posts.
- No automatic translation on post create/update.
- No "Fill missing translations" batch tool.
### Implementation Plan
#### 4.0 Extract `SUPPORTED_POST_LANGUAGES` Constant
The list of supported post languages is currently hardcoded inline in AI
task files (e.g. `['en', 'de', 'fr', 'it', 'es']`). Extract it into a
shared constant in `src/main/shared/` (or similar) so that both AI tasks
and the Blog Languages UI (§4.1) reference a single source of truth.
#### 4.1 Project Preferences — Blog Languages
Add `blogLanguages?: string[]` to `ProjectMetadata`. This is the list of
languages the blog is rendered in (e.g. `['en', 'de']`). The `mainLanguage`
is always implicitly included. When `blogLanguages` is empty or absent, the
blog renders in `mainLanguage` only (current behaviour).
**UI**: Add a "Blog Languages" multi-select in the Project Settings panel,
populated from `SUPPORTED_POST_LANGUAGES`. The main language is shown but
cannot be removed. i18n keys: `settings.project.blogLanguagesLabel`,
`settings.project.blogLanguagesDescription`.
#### 4.2 Do-Not-Translate Flag
Add a boolean `doNotTranslate` column to the `posts` table (default false).
Persist in YAML frontmatter as `doNotTranslate: true`. Migration required.
**UI**: Checkbox in the post editor metadata area, labelled via i18n
(`editor.doNotTranslateLabel`).
**Validate Translations** must detect posts marked `doNotTranslate` that
still have translations and offer to remove them.
#### 4.3 Automatic Translation on Post Create/Update
When a canonical post is created or updated and `blogLanguages` contains
languages beyond `mainLanguage`:
1. For each active blog language missing a translation (skip if
`doNotTranslate` is set), enqueue a `TaskManager` task calling
`chat:translatePost`.
2. On success, show a toast ("Translated to French"). On failure, show an
error toast. Task progress is visible in the task panel.
3. Only canonical content changes trigger re-translation. Editing a
translation directly does **not** re-trigger anything.
4. After each post translation succeeds, cascade to linked media: for every
media item linked via `postMedia` that lacks a translation for the target
language, enqueue `chat:translateMediaMetadata`.
#### 4.4 Fill Missing Translations (Blog Menu Tool)
Add a "Fill Missing Translations" menu item under the Blog menu.
1. Scan all published posts (excluding `doNotTranslate`) and all linked media
for missing translations across `blogLanguages`.
2. Create one task for post translations and a second task for media metadata
translations.
3. Report progress and partial failures via the task panel and toasts.
4. This is separate from Validate Translations — validate checks consistency,
fill adds missing content.
#### 4.5 Route Generation — Main Language Flat, Alternatives Prefixed
The main language keeps the current flat route structure. Only additional
blog languages get a language-prefixed subtree. This means single-language
blogs see zero change from today's output.
```
html/
index.html ← main language (flat, same as today)
page/2/index.html
2025/03/08/my-post/index.html
category/tech/index.html
tag/rust/index.html
rss.xml
atom.xml
de/ ← additional blog language subtree
index.html
page/2/index.html
2025/03/08/my-post/index.html
category/tech/index.html
tag/rust/index.html
rss.xml
atom.xml
sitemap.xml ← combined, with hreflang alternates
media/ ← shared, not duplicated
assets/ ← shared, not duplicated
```
For the main language pass, generation works exactly as today — no prefix,
no routing changes. For each additional language in `blogLanguages`:
- Iterate the same route list, writing output under `/{lang}/…`.
- Resolve every post through `resolveRenderablePost(post, engine, lang)`.
If no translation exists, fall back to canonical content.
- Same for media metadata in macros: `getMediaTranslation(id, lang)` with
canonical fallback.
- All internal links within a language subtree stay prefixed (`/de/…` links
to `/de/…`). Main-language links remain unprefixed (`/2025/…`).
- Posts marked `doNotTranslate` render only in the main language output.
They are omitted from alternative language subtrees entirely.
#### 4.6 Per-Language Feeds
The main language feeds (`rss.xml`, `atom.xml`) stay at the root as today.
Each alternative language subtree gets its own `rss.xml` and `atom.xml`
under `/{lang}/`, containing only posts available in that language, with
URLs pointing into the language subtree. Feed `<language>` / `xml:lang` is
set to the subtree language.
#### 4.7 Combined Sitemap with hreflang
The root `sitemap.xml` lists all language variants of every URL. Each `<url>`
entry includes `<xhtml:link rel="alternate" hreflang="…" href="…"/>` for
every language the post is available in, plus `x-default` pointing to the
main language variant.
#### 4.8 Language Switcher in Templates
Add a `blogLanguages` array and `currentLanguage` string to the Liquid
template context. Default templates render a language switcher bar (flag
badges) at the top linking to the same page in each available language.
The switcher links are absolute paths — unprefixed for the main language
(`/2025/03/08/my-post/`) and prefixed for alternatives
(`/de/2025/03/08/my-post/`) — so they work regardless of route depth.
#### 4.9 Preview Server — Language-Prefixed Routes
Extend `PreviewServer` to handle language-prefixed paths for alternative
languages so preview matches the generated output:
- `GET /2025/03/08/my-post` → render post in main language (unchanged).
- `GET /de/category/tech` → render category list in German.
- For paths starting with a known alternative language prefix, strip it and
pass the language as `preferredLanguage` to `renderRouteForContext()`.
- Unprefixed paths use `mainLanguage` (current behaviour, no change).
- Keep the existing `?lang=` parameter as a fallback for single-post preview
from the editor.
- Language switcher links in preview HTML work because they use the same
prefix scheme as generation.
This ensures the user sees the exact same route structure and language
switching behaviour in preview as in the generated output.
#### 4.10 Preview/Generation Parity Checklist
Both preview and generation must produce identical output for:
- [ ] Main language routes remain flat/unprefixed.
- [ ] Alternative language routes use `/{lang}/…` prefix.
- [ ] Post content: translated title, excerpt, body with canonical fallback.
- [ ] Media metadata in macros (gallery, photo_album): translated alt/title/
caption with canonical fallback.
- [ ] Internal links: unprefixed for main language, prefixed for alternatives.
- [ ] Language switcher rendering with correct cross-language links.
- [ ] Per-language feed links in HTML `<head>`.
- [ ] `doNotTranslate` posts omitted from alternative language subtrees.
- [ ] Root `/` renders main language content (unchanged from today).
Shared implementation: both paths go through `SharedRouteRenderer` →
`PageRenderer`, so language handling logic added there automatically applies
to both preview and generation. The key change is making
`SharedRouteRenderer` language-prefix-aware and ensuring
`BlogGenerationEngine` iterates over `blogLanguages` when building routes.
#### 4.11 Testing
- **Unit**: Route prefix stripping, language fallback resolution, feed
language filtering, `doNotTranslate` exclusion, sitemap hreflang building.
- **Integration**: End-to-end generation with two languages produces correct
subtree structure, shared assets, per-language feeds, combined sitemap.
- **Preview parity**: Same route in preview and generation produces identical
HTML (modulo asset URLs).

View File

@@ -0,0 +1,17 @@
CREATE TABLE `post_translations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`translation_for` text NOT NULL,
`language` text NOT NULL,
`title` text NOT NULL,
`excerpt` text,
`content` text,
`status` text DEFAULT 'draft' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`published_at` integer,
`file_path` text DEFAULT '' NOT NULL,
`checksum` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `post_translations_translation_language_idx` ON `post_translations` (`translation_for`,`language`);

View File

@@ -0,0 +1,14 @@
CREATE TABLE `media_translations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`translation_for` text NOT NULL,
`language` text NOT NULL,
`title` text,
`alt` text,
`caption` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `media_translations_translation_language_idx` ON `media_translations` (`translation_for`,`language`);--> statement-breakpoint
ALTER TABLE `media` ADD `language` text;

View File

@@ -0,0 +1 @@
ALTER TABLE `posts` ADD `do_not_translate` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,27 @@
"when": 1772738580546,
"tag": "0012_flimsy_meteorite",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1772882271754,
"tag": "0013_swift_devos",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1772959578621,
"tag": "0014_media_translations",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1772967539676,
"tag": "0015_melted_zzzax",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,106 @@
"""
Audit translation quality — run inside bDS Python runtime.
Flags posts where translation content is suspicious:
- Canonical body is empty/tiny but translation has substantial content (hallucination)
- Translation is much longer than canonical (ratio > 3x)
- Canonical is macro-only but translation contains prose
"""
from bds_api import bds
MACRO_PATTERN = r'^\s*(\[\[.*?\]\]\s*)+$'
SIZE_RATIO_THRESHOLD = 3.0
MIN_HALLUCINATION_SIZE = 200 # translation must be at least this big to flag
async def audit():
import re
# Get all published posts, paginated
all_posts = []
offset = 0
limit = 100
while True:
result = await bds.posts.get_all(options={'limit': limit, 'offset': offset})
items = result.get('items', result) if isinstance(result, dict) else result
if isinstance(result, dict):
all_posts.extend(result['items'])
if not result.get('hasMore', False):
break
else:
all_posts.extend(result)
if len(result) < limit:
break
offset += limit
print(f"Total posts: {len(all_posts)}")
suspicious = []
for post in all_posts:
langs = post.get('availableLanguages', [])
canonical_lang = post.get('language', '')
# Only check posts that have translations
translation_langs = [l for l in langs if l != canonical_lang]
if not translation_langs:
continue
canonical_content = post.get('content', '') or ''
canonical_len = len(canonical_content.strip())
canonical_is_macro = bool(re.match(MACRO_PATTERN, canonical_content.strip(), re.DOTALL)) if canonical_content.strip() else False
canonical_is_empty = canonical_len < 20
# Get translations for this post
translations = await bds.posts.get_translations(post_id=post['id'])
if not translations:
continue
for tr in translations:
tr_content = tr.get('content', '') or ''
tr_len = len(tr_content.strip())
tr_lang = tr.get('language', '?')
reasons = []
# Check 1: empty/tiny canonical but substantial translation
if canonical_is_empty and tr_len > MIN_HALLUCINATION_SIZE:
reasons.append(f"empty canonical ({canonical_len}b) but translation has {tr_len}b")
# Check 2: macro-only canonical but translation has prose
if canonical_is_macro and tr_len > MIN_HALLUCINATION_SIZE:
tr_is_macro = bool(re.match(MACRO_PATTERN, tr_content.strip(), re.DOTALL))
if not tr_is_macro:
reasons.append(f"canonical is macro-only but translation has prose ({tr_len}b)")
# Check 3: translation is disproportionately longer
if canonical_len > 20 and tr_len > 0:
ratio = tr_len / canonical_len
if ratio > SIZE_RATIO_THRESHOLD:
reasons.append(f"translation is {ratio:.1f}x longer ({canonical_len}b → {tr_len}b)")
if reasons:
suspicious.append({
'slug': post.get('slug', ''),
'title': post.get('title', ''),
'canonical_lang': canonical_lang,
'translation_lang': tr_lang,
'canonical_len': canonical_len,
'translation_len': tr_len,
'reasons': reasons,
})
# Print results
print(f"\nChecked {len(all_posts)} posts")
print(f"Found {len(suspicious)} suspicious translations:\n")
for s in suspicious:
print(f" {s['slug']} ({s['canonical_lang']}{s['translation_lang']})")
print(f" title: {s['title']}")
print(f" sizes: canonical={s['canonical_len']}b, translation={s['translation_len']}b")
for r in s['reasons']:
print(f"{r}")
print()
await audit()

View File

@@ -35,6 +35,7 @@ export const posts = sqliteTable('posts', {
categories: text('categories'), // JSON array stored as text
templateSlug: text('template_slug'), // Optional user template override for this post
language: text('language'), // Optional per-post language override (ISO code, e.g. 'en', 'de')
doNotTranslate: integer('do_not_translate', { mode: 'boolean' }).notNull().default(false), // Exclude from translation
// Legacy columns (kept for migration compatibility, no longer written)
publishedTitle: text('published_title'),
publishedContent: text('published_content'),
@@ -46,6 +47,24 @@ export const posts = sqliteTable('posts', {
projectSlugIdx: uniqueIndex('posts_project_slug_idx').on(table.projectId, table.slug),
}));
export const postTranslations = sqliteTable('post_translations', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
translationFor: text('translation_for').notNull(),
language: text('language').notNull(),
title: text('title').notNull(),
excerpt: text('excerpt'),
content: text('content'),
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }),
filePath: text('file_path').notNull().default(''),
checksum: text('checksum'),
}, (table) => ({
translationLanguageIdx: uniqueIndex('post_translations_translation_language_idx').on(table.translationFor, table.language),
}));
// Media table - stores metadata for images and other media
export const media = sqliteTable('media', {
projectId: text('project_id').notNull(),
@@ -66,8 +85,28 @@ export const media = sqliteTable('media', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
language: text('language'), // Optional per-media language override (ISO code, e.g. 'en', 'de')
});
// Media translations table - stores localized metadata for media items
// The binary asset remains shared; only title, alt, and caption vary by language.
export const mediaTranslations = sqliteTable('media_translations', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
translationFor: text('translation_for').notNull(),
language: text('language').notNull(),
title: text('title'),
alt: text('alt'),
caption: text('caption'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
translationLanguageIdx: uniqueIndex('media_translations_translation_language_idx').on(table.translationFor, table.language),
}));
export type MediaTranslation = typeof mediaTranslations.$inferSelect;
export type NewMediaTranslation = typeof mediaTranslations.$inferInsert;
// App settings - stores application configuration
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
@@ -294,6 +333,8 @@ export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type PostTranslation = typeof postTranslations.$inferSelect;
export type NewPostTranslation = typeof postTranslations.$inferInsert;
export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type Setting = typeof settings.$inferSelect;

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 }}">

View File

@@ -8,11 +8,18 @@ import {
} from '../engine/BlogGenerationEngine';
import { resolvePageTitle } from '../engine/PageRenderer';
import type { EngineBundle } from '../engine/EngineBundle';
import type { TranslationValidationReport } from '../shared/electronApi';
import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
import { v4 as uuidv4 } from 'uuid';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
const resolveActiveProjectContext = async (): Promise<{
project: NonNullable<Awaited<ReturnType<EngineBundle['projectEngine']['getActiveProject']>>>;
dataDir: string;
metadata: Awaited<ReturnType<EngineBundle['metaEngine']['getProjectMetadata']>>;
}> => {
const projectEngine = bundle.projectEngine;
const postEngine = bundle.postEngine;
const metaEngine = bundle.metaEngine;
@@ -37,6 +44,17 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
}
const metadata = await metaEngine.getProjectMetadata();
return {
project,
dataDir,
metadata,
};
};
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
const menuEngine = bundle.menuEngine;
const { project, dataDir, metadata } = await resolveActiveProjectContext();
const menu = await menuEngine.getMenu();
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
if (!baseUrl) {
@@ -60,6 +78,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
baseUrl,
maxPostsPerPage: metadata?.maxPostsPerPage,
language,
blogLanguages: Array.isArray(metadata?.blogLanguages) ? metadata.blogLanguages : [],
pageTitle,
picoTheme: metadata?.picoTheme,
categoryMetadata: (metadata as any)?.categoryMetadata,
@@ -145,6 +164,142 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
});
});
safeHandle('blog:validateTranslations', async () => {
await resolveActiveProjectContext();
const taskTimestamp = Date.now();
return bundle.taskManager.runTask({
id: `translation-validate-${taskTimestamp}`,
name: 'Validate Translations',
execute: async (onProgress) => {
onProgress(0, 'Validating translations...');
const result = await bundle.postEngine.validateTranslations();
onProgress(100, 'Translation validation complete');
return result;
},
});
});
safeHandle('blog:fixInvalidTranslations', async (_event, report: TranslationValidationReport) => {
await resolveActiveProjectContext();
const taskTimestamp = Date.now();
return bundle.taskManager.runTask({
id: `translation-fix-${taskTimestamp}`,
name: 'Fix Invalid Translations',
execute: async (onProgress) => {
onProgress(0, 'Fixing invalid translations...');
const result = await bundle.postEngine.fixInvalidTranslations(report);
onProgress(100, 'Invalid translations fixed');
return result;
},
});
});
safeHandle('blog:fillMissingTranslations', async () => {
const { metadata } = await resolveActiveProjectContext();
const blogLanguages = metadata?.blogLanguages || [];
const mainLang = metadata?.mainLanguage || 'en';
if (blogLanguages.length === 0 || (blogLanguages.length === 1 && blogLanguages[0] === mainLang)) {
return { taskStarted: false };
}
// Start the task immediately — scanning happens inside with progress
bundle.taskManager.runTask({
id: uuidv4(),
name: 'Fill missing translations',
execute: async (onProgress) => {
onProgress(0, 'Scanning posts…');
// Use missingTranslationLanguage filter per language instead of N+1 queries
const postItems: Array<{ postId: string; postTitle: string; targetLang: string }> = [];
for (let i = 0; i < blogLanguages.length; i++) {
const lang = blogLanguages[i];
const postsNeedingLang = await bundle.postEngine.getPostsFiltered({
status: 'published',
missingTranslationLanguage: lang,
});
for (const post of postsNeedingLang) {
if (!post.doNotTranslate) {
postItems.push({ postId: post.id, postTitle: post.title, targetLang: lang });
}
}
onProgress(
Math.round(((i + 1) / blogLanguages.length) * 10),
`Scanning posts… (${i + 1}/${blogLanguages.length} languages)`,
);
}
onProgress(10, 'Scanning media…');
// Collect missing media translations
const allPublished = await bundle.postEngine.getPostsFiltered({ status: 'published' });
const publishedPosts = allPublished.filter((p) => !p.doNotTranslate);
const mediaItems: Array<{ mediaId: string; targetLang: string }> = [];
const seenMediaLang = new Set<string>();
for (let i = 0; i < publishedPosts.length; i++) {
const post = publishedPosts[i];
const postLang = post.language || mainLang;
const links = await bundle.postMediaEngine.getLinkedMediaForPost(post.id);
for (const link of links) {
const mediaTranslations = await bundle.mediaEngine.getMediaTranslations(link.mediaId);
const existingLangs = new Set(mediaTranslations.map((t) => t.language));
for (const lang of blogLanguages) {
const key = `${link.mediaId}:${lang}`;
if (lang !== postLang && !existingLangs.has(lang) && !seenMediaLang.has(key)) {
seenMediaLang.add(key);
mediaItems.push({ mediaId: link.mediaId, targetLang: lang });
}
}
}
onProgress(10 + Math.round(((i + 1) / publishedPosts.length) * 5), `Scanning media… (${i + 1}/${publishedPosts.length})`);
}
const totalItems = postItems.length + mediaItems.length;
if (totalItems === 0) {
onProgress(100, 'All translations are up to date');
return;
}
onProgress(15, `Found ${postItems.length} posts and ${mediaItems.length} media to translate`);
let completed = 0;
let failed = 0;
let warned = 0;
for (const item of postItems) {
onProgress(15 + Math.round((completed / totalItems) * 85), `Translating "${item.postTitle}" → ${item.targetLang}`);
const result = await autoTranslatePost(item.postId, item.targetLang, { autoPublish: true });
if (!result.success) {
failed++;
console.error(`[FillMissing] post "${item.postTitle}" → ${item.targetLang} failed:`, result.error);
} else if (result.warning) {
warned++;
console.warn(`[FillMissing] post "${item.postTitle}" → ${item.targetLang}: ${result.warning}`);
}
completed++;
}
for (const item of mediaItems) {
onProgress(15 + Math.round((completed / totalItems) * 85), `Translating media ${item.mediaId.slice(0, 8)}… → ${item.targetLang}`);
const result = await autoTranslateMediaMetadata(item.mediaId, item.targetLang);
if (!result.success) {
failed++;
console.error(`[FillMissing] media ${item.mediaId.slice(0, 8)}… → ${item.targetLang} failed:`, result.error);
}
completed++;
}
const parts: string[] = ['Done'];
if (failed > 0) parts.push(`${failed} failed`);
if (warned > 0) parts.push(`${warned} warnings`);
onProgress(100, parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(', ')})` : parts[0]);
},
}).catch(() => { /* errors tracked via task panel */ });
return { taskStarted: true };
});
safeHandle('blog:regenerateCalendar', async () => {
const blogGenerationEngine = bundle.blogGenerationEngine;
const baseOptions = await resolveBlogGenerationBaseOptions();

View File

@@ -10,6 +10,7 @@ import { SecureKeyStore } from '../engine/SecureKeyStore';
import { ProviderRegistry } from '../engine/ai/providers';
import { ChatService } from '../engine/ai/chat';
import { OneShotTasks } from '../engine/ai/tasks';
import { retryWithBackoff } from '../engine/ai/retry';
import { getDatabase } from '../database';
import type { EngineBundle } from '../engine/EngineBundle';
import type { BlogToolDeps } from '../engine/ai/blog-tools';
@@ -907,6 +908,43 @@ export function registerChatHandlers(): void {
}
});
// Translate a post and persist/update its translation record
ipcMain.handle('chat:translatePost', async (_, postId: string, targetLanguage: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().translatePost(postId, targetLanguage || 'en');
} catch (error) {
console.error('[Chat IPC] Error translating post:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ Media Language Detection ============
// Detect the language of media metadata (title, alt, caption)
ipcMain.handle('chat:detectMediaLanguage', async (_, title: string, alt: string, caption: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().detectMediaLanguage(title, alt, caption);
} catch (error) {
console.error('[Chat IPC] Error detecting media language:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ Media Metadata Translation ============
// Translate media metadata (title, alt, caption) into a target language
ipcMain.handle('chat:translateMediaMetadata', async (_, mediaId: string, targetLanguage: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().translateMediaMetadata(mediaId, targetLanguage);
} catch (error) {
console.error('[Chat IPC] Error translating media metadata:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ A2UI Actions ============
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {
@@ -922,6 +960,31 @@ export function registerChatHandlers(): void {
});
}
/**
* Translate a post for auto-translation workflows (called from event handlers).
* Returns the result of translatePost or an error object if AI is not initialized.
*/
export async function autoTranslatePost(postId: string, targetLanguage: string, options?: { autoPublish?: boolean }): Promise<{ success: boolean; error?: string; warning?: string }> {
try {
await ensureInitialized();
return await retryWithBackoff(() => getOneShotTasks().translatePost(postId, targetLanguage, options));
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Translate media metadata for auto-translation workflows (called from event handlers).
*/
export async function autoTranslateMediaMetadata(mediaId: string, targetLanguage: string): Promise<{ success: boolean; error?: string }> {
try {
await ensureInitialized();
return await retryWithBackoff(() => getOneShotTasks().translateMediaMetadata(mediaId, targetLanguage));
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Cleanup chat resources
*/

View File

@@ -21,6 +21,8 @@ import { registerEmbeddingHandlers } from './embeddingHandlers';
import { isOfflineModeActive } from './chatHandlers';
import type { EngineBundle } from '../engine/EngineBundle';
import { resolveUiLanguageFromSystemLocale, translateMenu } from '../shared/i18n';
import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
import { v4 as uuidv4 } from 'uuid';
/**
* Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -433,6 +435,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
postEngine.setSearchLanguage(stemmerLang);
mediaEngine.setSearchLanguage(stemmerLang);
postEngine.setMainLanguage(metadata.mainLanguage);
}
}
@@ -475,6 +478,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
postEngine.setSearchLanguage(stemmerLang);
mediaEngine.setSearchLanguage(stemmerLang);
postEngine.setMainLanguage(metadata.mainLanguage);
}
}
@@ -537,7 +541,27 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.getPostBySlug(slug);
});
safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => {
safeHandle('posts:getTranslation', async (_, postId: string, language: string) => {
const engine = bundle.postEngine;
return engine.getPostTranslation(postId, language);
});
safeHandle('posts:getTranslations', async (_, postId: string) => {
const engine = bundle.postEngine;
return engine.getPostTranslations(postId);
});
safeHandle('posts:upsertTranslation', async (_, postId: string, language: string, data: Record<string, unknown>) => {
const engine = bundle.postEngine;
return engine.upsertPostTranslation(postId, language, data as never);
});
safeHandle('posts:publishTranslation', async (_, postId: string, language: string) => {
const engine = bundle.postEngine;
return engine.publishPostTranslation(postId, language);
});
safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean; lang?: string }) => {
const engine = bundle.postEngine;
const post = await engine.getPost(id);
@@ -548,7 +572,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
if (options?.draft) {
return `http://127.0.0.1:4123${canonicalPath}?draft=true&postId=${encodeURIComponent(id)}`;
const params = new URLSearchParams({ draft: 'true', postId: id });
if (options.lang?.trim()) {
params.set('lang', options.lang.trim().toLowerCase());
}
return `http://127.0.0.1:4123${canonicalPath}?${params.toString()}`;
}
if (options?.lang?.trim()) {
return `http://127.0.0.1:4123${canonicalPath}?lang=${encodeURIComponent(options.lang.trim().toLowerCase())}`;
}
return `http://127.0.0.1:4123${canonicalPath}`;
@@ -872,6 +904,28 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.regenerateMissingThumbnails();
});
// ============ Media Translation Handlers ============
safeHandle('media:getTranslation', async (_, mediaId: string, language: string) => {
const engine = bundle.mediaEngine;
return engine.getMediaTranslation(mediaId, language);
});
safeHandle('media:getTranslations', async (_, mediaId: string) => {
const engine = bundle.mediaEngine;
return engine.getMediaTranslations(mediaId);
});
safeHandle('media:upsertTranslation', async (_, mediaId: string, language: string, data: { title?: string; alt?: string; caption?: string }) => {
const engine = bundle.mediaEngine;
return engine.upsertMediaTranslation(mediaId, language, data);
});
safeHandle('media:deleteTranslation', async (_, mediaId: string, language: string) => {
const engine = bundle.mediaEngine;
return engine.deleteMediaTranslation(mediaId, language);
});
// ============ Script Handlers ============
safeHandle('scripts:create', async (_, data: CreateScriptInput) => {
@@ -1810,10 +1864,65 @@ export function registerEventForwarding(bundle: EngineBundle): void {
postEngine.on('rebuildStarted', forwardEvent('posts:rebuildStarted'));
postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt'));
// Auto-translate: when a canonical post is created or updated, enqueue
// translation tasks for each blog language that does not yet have a translation.
const enqueueAutoTranslations = (post: PostData) => {
if (post.doNotTranslate) return;
metaEngine.getProjectMetadata().then(async (metadata) => {
if (!metadata) return;
const blogLanguages = metadata.blogLanguages || [];
const mainLang = metadata.mainLanguage || 'en';
const postLang = post.language || mainLang;
const targetLanguages = blogLanguages.filter((lang) => lang !== postLang);
if (targetLanguages.length === 0) return;
const existingTranslations = await postEngine.getPostTranslations(post.id);
const existingLangs = new Set(existingTranslations.map((t) => t.language));
const missingLanguages = targetLanguages.filter((lang) => !existingLangs.has(lang));
if (missingLanguages.length === 0) return;
const groupId = uuidv4();
for (const targetLang of missingLanguages) {
bundle.taskManager.runTask({
id: uuidv4(),
name: `Translate "${post.title}" → ${targetLang}`,
groupId,
groupName: `Auto-translate: ${post.title}`,
execute: async (onProgress) => {
onProgress(10, `Translating to ${targetLang}...`);
const result = await autoTranslatePost(post.id, targetLang);
if (!result.success) {
throw new Error(result.error || `Translation to ${targetLang} failed`);
}
onProgress(70, `Translating linked media...`);
// Cascade: translate linked media metadata
const links = await bundle.postMediaEngine.getLinkedMediaForPost(post.id);
for (const link of links) {
const mediaTranslations = await bundle.mediaEngine.getMediaTranslations(link.mediaId);
const hasLang = mediaTranslations.some((t) => t.language === targetLang);
if (!hasLang) {
await autoTranslateMediaMetadata(link.mediaId, targetLang).catch(() => {});
}
}
onProgress(100, 'Done');
},
}).catch((error) => {
console.error(`[Auto-translate] Failed for ${post.id}${targetLang}:`, error);
});
}
}).catch(() => {});
};
postEngine.on('postCreated', (post: PostData) => enqueueAutoTranslations(post));
postEngine.on('postUpdated', (post: PostData) => enqueueAutoTranslations(post));
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
mediaEngine.on('mediaFileReplaced', forwardEvent('media:fileReplaced'));
mediaEngine.on('mediaTranslationCreated', forwardEvent('media:translationCreated'));
mediaEngine.on('mediaTranslationUpdated', forwardEvent('media:translationUpdated'));
mediaEngine.on('mediaTranslationDeleted', forwardEvent('media:translationDeleted'));
mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted'));
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));

View File

@@ -47,7 +47,7 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle, bundle: Eng
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
await withProjectContext(bundle);
return engine().runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
return engine().runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language' | 'translationFor', groupLabel);
});
// ── Media ──

View File

@@ -522,6 +522,7 @@ async function initializeActiveProjectContext(): Promise<void> {
const postEngine = bundle!.postEngine as {
setProjectContext?: (projectId: string, dataDir?: string) => void;
setSearchLanguage?: (language: string) => void;
setMainLanguage?: (language: string) => void;
};
const mediaEngine = bundle!.mediaEngine as {
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
@@ -553,6 +554,7 @@ async function initializeActiveProjectContext(): Promise<void> {
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
postEngine.setSearchLanguage?.(stemmerLang);
mediaEngine.setSearchLanguage?.(stemmerLang);
postEngine.setMainLanguage?.(metadata.mainLanguage);
}
} catch (error) {
console.error('Failed to initialize active project context:', error);
@@ -684,15 +686,16 @@ function createApplicationMenu(): Menu {
};
}
return {
const item: MenuItemConstructorOptions = {
label: translatedLabel,
accelerator: definition.accelerator,
id: definition.id,
enabled: definition.enabled,
click: async () => {
await triggerMenuAction(action);
},
};
if (definition.accelerator) item.accelerator = definition.accelerator;
if (definition.id) item.id = definition.id;
if (definition.enabled !== undefined) item.enabled = definition.enabled;
return item;
};
const buildSharedGroupMenuItems = (groupLabel: string): MenuItemConstructorOptions[] => {

View File

@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron';
import type { ElectronAPI } from './shared/electronApi';
import type { GitInitProgress } from './shared/electronApi';
import type { SiteValidationReport } from './shared/electronApi';
import type { TranslationValidationReport } from './shared/electronApi';
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
@@ -55,7 +56,11 @@ export const electronAPI: ElectronAPI = {
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
get: (id: string) => ipcRenderer.invoke('posts:get', id),
getBySlug: (slug: string) => ipcRenderer.invoke('posts:getBySlug', slug),
getPreviewUrl: (id: string, options?: { draft?: boolean }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options),
getTranslation: (postId: string, language: string) => ipcRenderer.invoke('posts:getTranslation', postId, language),
getTranslations: (postId: string) => ipcRenderer.invoke('posts:getTranslations', postId),
upsertTranslation: (postId: string, language: string, data: unknown) => ipcRenderer.invoke('posts:upsertTranslation', postId, language, data),
publishTranslation: (postId: string, language: string) => ipcRenderer.invoke('posts:publishTranslation', postId, language),
getPreviewUrl: (id: string, options?: { draft?: boolean; lang?: string }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options),
getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
@@ -100,6 +105,10 @@ export const electronAPI: ElectronAPI = {
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
getTranslation: (mediaId: string, language: string) => ipcRenderer.invoke('media:getTranslation', mediaId, language),
getTranslations: (mediaId: string) => ipcRenderer.invoke('media:getTranslations', mediaId),
upsertTranslation: (mediaId: string, language: string, data: unknown) => ipcRenderer.invoke('media:upsertTranslation', mediaId, language, data),
deleteTranslation: (mediaId: string, language: string) => ipcRenderer.invoke('media:deleteTranslation', mediaId, language),
},
// Scripts
@@ -300,6 +309,9 @@ export const electronAPI: ElectronAPI = {
blog: {
generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'),
validateSite: () => ipcRenderer.invoke('blog:validateSite'),
validateTranslations: () => ipcRenderer.invoke('blog:validateTranslations'),
fixInvalidTranslations: (report: TranslationValidationReport) => ipcRenderer.invoke('blog:fixInvalidTranslations', report),
fillMissingTranslations: () => ipcRenderer.invoke('blog:fillMissingTranslations'),
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'),
},
@@ -396,6 +408,15 @@ export const electronAPI: ElectronAPI = {
// Post Analysis (title, excerpt, slug suggestions)
analyzePost: (postId: string, language?: string) => ipcRenderer.invoke('chat:analyzePost', postId, language),
// Post Translation
translatePost: (postId: string, targetLanguage: string) => ipcRenderer.invoke('chat:translatePost', postId, targetLanguage),
// Media Language Detection
detectMediaLanguage: (mediaId: string) => ipcRenderer.invoke('chat:detectMediaLanguage', mediaId),
// Media Metadata Translation
translateMediaMetadata: (mediaId: string, targetLanguage: string) => ipcRenderer.invoke('chat:translateMediaMetadata', mediaId, targetLanguage),
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);

View File

@@ -96,18 +96,37 @@ export interface PostData {
status: 'draft' | 'published' | 'archived';
author?: string;
language?: string;
doNotTranslate?: boolean;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
availableLanguages: string[];
templateSlug?: string;
}
export interface PostTranslationData {
id: string;
projectId: string;
translationFor: string;
language: string;
title: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
createdAt: string;
updatedAt: string;
publishedAt?: string;
filePath: string;
}
export interface PostFilter {
status?: 'draft' | 'published' | 'archived';
tags?: string[];
categories?: string[];
language?: string;
missingTranslationLanguage?: string;
year?: number;
month?: number;
from?: string;
@@ -134,15 +153,31 @@ export interface MediaData {
alt?: string;
caption?: string;
author?: string;
language?: string;
createdAt: string;
updatedAt: string;
tags: string[];
availableLanguages: string[];
}
export interface MediaTranslationData {
id: string;
projectId: string;
translationFor: string;
language: string;
title?: string;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
}
export interface MediaFilter {
tags?: string[];
year?: number;
month?: number;
language?: string;
missingTranslationLanguage?: string;
}
export interface MediaSearchResult {
@@ -533,6 +568,29 @@ export interface SiteValidationReport {
existingHtmlUrlCount: number;
}
export interface TranslationValidationIssue {
issue: 'same-language-as-canonical' | 'missing-source-post' | 'do-not-translate-has-translations' | 'content-in-database';
translationId?: string;
translationFor: string;
canonicalLanguage?: string;
translationLanguage: string;
title?: string;
filePath?: string;
}
export interface TranslationValidationReport {
checkedDatabaseRowCount: number;
checkedFilesystemFileCount: number;
invalidDatabaseRows: TranslationValidationIssue[];
invalidFilesystemFiles: TranslationValidationIssue[];
}
export interface TranslationValidationFixResult {
deletedDatabaseRows: number;
deletedFiles: number;
flushedTranslations: number;
}
export interface SiteValidationApplyResult {
renderedUrlCount: number;
deletedUrlCount: number;
@@ -596,7 +654,11 @@ export interface ElectronAPI {
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getBySlug: (slug: string) => Promise<PostData | null>;
getPreviewUrl: (id: string, options?: { draft?: boolean }) => Promise<string | null>;
getTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
getTranslations: (postId: string) => Promise<PostTranslationData[]>;
upsertTranslation: (postId: string, language: string, data: Partial<PostTranslationData>) => Promise<PostTranslationData>;
publishTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
getPreviewUrl: (id: string, options?: { draft?: boolean; lang?: string }) => Promise<string | null>;
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
@@ -639,6 +701,10 @@ export interface ElectronAPI {
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getTags: () => Promise<string[]>;
getTagsWithCounts: () => Promise<TagCount[]>;
getTranslation: (mediaId: string, language: string) => Promise<MediaTranslationData | null>;
getTranslations: (mediaId: string) => Promise<MediaTranslationData[]>;
upsertTranslation: (mediaId: string, language: string, data: Partial<MediaTranslationData>) => Promise<MediaTranslationData>;
deleteTranslation: (mediaId: string, language: string) => Promise<boolean>;
};
scripts: {
create: (data: {
@@ -747,7 +813,7 @@ export interface ElectronAPI {
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
getProjectMetadata: () => Promise<ProjectMetadata | null>;
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings>; semanticSimilarityEnabled?: boolean }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings>; semanticSimilarityEnabled?: boolean; blogLanguages?: string[] }) => Promise<ProjectMetadata | null>;
getPublishingPreferences: () => Promise<PublishingPreferences | null>;
setPublishingPreferences: (prefs: PublishingPreferences) => Promise<void>;
clearPublishingPreferences: () => Promise<void>;
@@ -897,6 +963,9 @@ export interface ElectronAPI {
pagesGenerated: number;
}>;
validateSite: () => Promise<SiteValidationReport>;
validateTranslations: () => Promise<TranslationValidationReport>;
fixInvalidTranslations: (report: TranslationValidationReport) => Promise<TranslationValidationFixResult>;
fillMissingTranslations: () => Promise<{ taskStarted: boolean }>;
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
regenerateCalendar: () => Promise<CalendarRegenerationResult>;
};
@@ -997,6 +1066,15 @@ export interface ElectronAPI {
// Post Analysis (title, excerpt, slug suggestions)
analyzePost: (postId: string, language?: string) => Promise<{ success: boolean; title?: string; excerpt?: string; slug?: string; error?: string }>;
// Post Translation
translatePost: (postId: string, targetLanguage: string) => Promise<{ success: boolean; translation?: PostTranslationData; error?: string }>;
// Media Language Detection
detectMediaLanguage: (mediaId: string) => Promise<{ success: boolean; language?: string; error?: string }>;
// Media Metadata Translation
translateMediaMetadata: (mediaId: string, targetLanguage: string) => Promise<{ success: boolean; translation?: MediaTranslationData; error?: string }>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;

View File

@@ -7,6 +7,20 @@ import esJson from './i18n/locales/es.json';
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
export const SUPPORTED_RENDER_LANGUAGES: SupportedLanguage[] = ['en', 'de', 'fr', 'it', 'es'];
/**
* Canonical list of languages supported for post and media translations.
* Used by both AI tasks and the Blog Languages UI.
*/
export const SUPPORTED_POST_LANGUAGES: readonly SupportedLanguage[] = SUPPORTED_RENDER_LANGUAGES;
export const POST_LANGUAGE_FLAGS: Record<SupportedLanguage, string> = {
en: '🇬🇧',
de: '🇩🇪',
fr: '🇫🇷',
it: '🇮🇹',
es: '🇪🇸',
};
type TranslationMap = Record<string, string>;
const en = enJson as TranslationMap;
@@ -47,6 +61,10 @@ export function resolveUiLanguageFromSystemLocale(systemLocale: string | undefin
return normalizeLanguage(systemLocale);
}
export function getRenderTranslations(language: SupportedLanguage): Record<string, string> {
return catalog[language] ?? catalog.en;
}
export function translateRender(language: SupportedLanguage, key: string): string {
return catalog[language]?.[key] ?? key;
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Site rendern",
"menu.item.regenerateCalendar": "Kalender neu erzeugen",
"menu.item.validateSite": "Website validieren",
"menu.item.validateTranslations": "Übersetzungen validieren",
"menu.item.findDuplicates": "Doppelte Beiträge finden",
"menu.item.uploadSite": "Website hochladen",
"menu.item.about": "Über Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Verlinkt von",
"render.backlinks.ariaLabel": "Rückverweise",
"render.languageSwitcher.ariaLabel": "Sprache",
"render.video.youtubeTitle": "YouTube-Video",
"render.video.vimeoTitle": "Vimeo-Video",
"render.month.1": "Januar",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Embedding-Index neu aufbauen",
"task.rebuildEmbeddingIndex.clearing": "Index wird geleert…",
"task.duplicateSearch.name": "Doppelte Beiträge finden",
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}"
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}",
"menu.item.fillMissingTranslations": "Fehlende Übersetzungen ausfüllen"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Render Site",
"menu.item.regenerateCalendar": "Regenerate Calendar",
"menu.item.validateSite": "Validate Site",
"menu.item.validateTranslations": "Validate Translations",
"menu.item.findDuplicates": "Find Duplicate Posts",
"menu.item.uploadSite": "Upload Site",
"menu.item.about": "About Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomy",
"render.backlinks.label": "Linked from",
"render.backlinks.ariaLabel": "Backlinks",
"render.languageSwitcher.ariaLabel": "Language",
"render.video.youtubeTitle": "YouTube video",
"render.video.vimeoTitle": "Vimeo video",
"render.month.1": "January",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Rebuild Embedding Index",
"task.rebuildEmbeddingIndex.clearing": "Clearing index…",
"task.duplicateSearch.name": "Find Duplicate Posts",
"task.duplicateSearch.searching": "Checking: {checked}/{total}"
"task.duplicateSearch.searching": "Checking: {checked}/{total}",
"menu.item.fillMissingTranslations": "Fill Missing Translations"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Renderizar sitio",
"menu.item.regenerateCalendar": "Regenerar calendario",
"menu.item.validateSite": "Validar sitio",
"menu.item.validateTranslations": "Validar traducciones",
"menu.item.findDuplicates": "Buscar entradas duplicadas",
"menu.item.uploadSite": "Subir sitio",
"menu.item.about": "Acerca de Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomía",
"render.backlinks.label": "Enlazado desde",
"render.backlinks.ariaLabel": "Retroenlaces",
"render.languageSwitcher.ariaLabel": "Idioma",
"render.video.youtubeTitle": "Vídeo de YouTube",
"render.video.vimeoTitle": "Vídeo de Vimeo",
"render.month.1": "enero",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Reconstruir índice de embeddings",
"task.rebuildEmbeddingIndex.clearing": "Vaciando índice…",
"task.duplicateSearch.name": "Buscar entradas duplicadas",
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}"
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}",
"menu.item.fillMissingTranslations": "Completar traducciones faltantes"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Rendre le site",
"menu.item.regenerateCalendar": "Régénérer le calendrier",
"menu.item.validateSite": "Valider le site",
"menu.item.validateTranslations": "Valider les traductions",
"menu.item.findDuplicates": "Trouver les articles en double",
"menu.item.uploadSite": "Publier le site",
"menu.item.about": "À propos de Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Lié depuis",
"render.backlinks.ariaLabel": "Rétroliens",
"render.languageSwitcher.ariaLabel": "Langue",
"render.video.youtubeTitle": "Vidéo YouTube",
"render.video.vimeoTitle": "Vidéo Vimeo",
"render.month.1": "janvier",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Reconstruire l'index d'embeddings",
"task.rebuildEmbeddingIndex.clearing": "Vidage de l'index…",
"task.duplicateSearch.name": "Trouver les articles en double",
"task.duplicateSearch.searching": "Vérification : {checked}/{total}"
"task.duplicateSearch.searching": "Vérification : {checked}/{total}",
"menu.item.fillMissingTranslations": "Compléter les traductions manquantes"
}

View File

@@ -42,6 +42,7 @@
"menu.item.generateSitemap": "Renderizza sito",
"menu.item.regenerateCalendar": "Rigenera calendario",
"menu.item.validateSite": "Valida sito",
"menu.item.validateTranslations": "Valida traduzioni",
"menu.item.findDuplicates": "Trova post duplicati",
"menu.item.uploadSite": "Carica sito",
"menu.item.about": "Informazioni su Blogging Desktop Server",
@@ -67,6 +68,7 @@
"render.taxonomy.ariaLabel": "Tassonomia",
"render.backlinks.label": "Collegato da",
"render.backlinks.ariaLabel": "Retrocollegamenti",
"render.languageSwitcher.ariaLabel": "Lingua",
"render.video.youtubeTitle": "Video YouTube",
"render.video.vimeoTitle": "Video Vimeo",
"render.month.1": "gennaio",
@@ -91,5 +93,6 @@
"task.rebuildEmbeddingIndex.name": "Ricostruisci indice embeddings",
"task.rebuildEmbeddingIndex.clearing": "Svuotamento indice…",
"task.duplicateSearch.name": "Trova post duplicati",
"task.duplicateSearch.searching": "Controllo: {checked}/{total}"
"task.duplicateSearch.searching": "Controllo: {checked}/{total}",
"menu.item.fillMissingTranslations": "Completa le traduzioni mancanti"
}

View File

@@ -37,9 +37,11 @@ export type AppMenuAction =
| 'generateSitemap'
| 'regenerateCalendar'
| 'validateSite'
| 'validateTranslations'
| 'uploadSite'
| 'openDocumentation'
| 'openApiDocumentation'
| 'fillMissingTranslations'
| 'findDuplicates'
| 'about'
| 'viewOnGitHub'
@@ -126,18 +128,20 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'menu.item.publishSelected', action: 'publishSelected', accelerator: 'CmdOrCtrl+Shift+P' },
{ label: '', action: 'blog-separator-1', separator: true },
{ label: 'menu.item.previewPost', action: 'previewPost', id: APP_MENU_ITEM_IDS.previewPost, enabled: false, accelerator: 'CmdOrCtrl+Shift+V' },
{ label: 'menu.item.editMenu', action: 'editMenu' },
{ label: '', action: 'blog-separator-2', separator: true },
{ label: 'menu.item.rebuildDatabase', action: 'rebuildDatabase' },
{ label: 'menu.item.reindexText', action: 'reindexText' },
{ label: 'menu.item.rebuildEmbeddingIndex', action: 'rebuildEmbeddingIndex' },
{ label: '', action: 'blog-separator-3', separator: true },
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
{ label: 'menu.item.editMenu', action: 'editMenu' },
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
{ label: 'menu.item.validateTranslations', action: 'validateTranslations' },
{ label: 'menu.item.fillMissingTranslations', action: 'fillMissingTranslations' },
{ label: 'menu.item.findDuplicates', action: 'findDuplicates' },
{ label: '', action: 'blog-separator-4', separator: true },
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
{ label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' },
],
},
@@ -177,6 +181,8 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
generateSitemap: 'menu:generateSitemap',
regenerateCalendar: 'menu:regenerateCalendar',
validateSite: 'menu:validateSite',
validateTranslations: 'menu:validateTranslations',
fillMissingTranslations: 'menu:fillMissingTranslations',
findDuplicates: 'menu:findDuplicates',
uploadSite: 'menu:uploadSite',
openDocumentation: 'menu:openDocumentation',

View File

@@ -82,16 +82,19 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getBySlug', 'Fetch one post by slug.', [requiredString('slug')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getPreviewUrl', 'Get preview URL for post. options may include draft=true and lang=<language-code>.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
method('posts.getTranslation', 'Get a single translation for a post by language.', [requiredString('postId'), requiredString('language')], 'PostTranslationData | null'),
method('posts.getTranslations', 'Get all translations for a post.', [requiredString('postId')], 'PostTranslationData[]'),
method('posts.publishTranslation', 'Publish a specific translation of a post.', [requiredString('postId'), requiredString('language')], 'PostTranslationData | null'),
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
method('posts.filter', 'Filter posts by criteria, including optional language and missingTranslationLanguage filters.', [requiredObject('filter')], 'PostData[]'),
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
@@ -123,6 +126,11 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('media.getTags', 'Get all media tags.', [], 'string[]'),
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
method('media.getTranslation', 'Get a single translation for a media item by language.', [requiredString('mediaId'), requiredString('language')], 'MediaTranslationData | null'),
method('media.getTranslations', 'Get all translations for a media item.', [requiredString('mediaId')], 'MediaTranslationData[]'),
method('media.upsertTranslation', 'Create or update a media translation for a specific language.', [requiredString('mediaId'), requiredString('language'), requiredObject('data')], 'MediaTranslationData'),
method('media.deleteTranslation', 'Delete a media translation by language.', [requiredString('mediaId'), requiredString('language')], 'boolean'),
method('scripts.create', 'Create script. data must include: title (str), kind ("macro"|"utility"|"transform"), content (str). Optional: slug (str), entrypoint (str, defaults to "render"), enabled (bool).', [requiredObject('data')], 'ScriptData'),
method('scripts.update', 'Update script by id. data may include any of: title, kind, content, slug, entrypoint, enabled.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
@@ -192,6 +200,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.analyzeMediaImage', 'Analyze an image and generate title, alt text, and caption using AI.', [requiredString('mediaId'), optionalString('language')], 'ImageAnalysisResult'),
method('chat.detectPostLanguage', 'Detect the language of a post from its title and content.', [requiredString('title'), requiredString('content')], '{ success: boolean; language?: string; error?: string }'),
method('chat.analyzePost', 'Analyze a post and generate suggested title, excerpt, and slug using AI.', [requiredString('postId'), optionalString('language')], 'PostAnalysisResult'),
method('chat.translatePost', 'Translate a post into a target language and save it as a translation draft.', [requiredString('postId'), requiredString('targetLanguage')], 'PostTranslationResult'),
method('chat.detectMediaLanguage', 'Detect the language of media metadata from its title, alt text, and caption.', [requiredString('title'), requiredString('alt'), requiredString('caption')], '{ success: boolean; language?: string; error?: string }'),
method('chat.translateMediaMetadata', 'Translate media metadata (title, alt, caption) into a target language using AI.', [requiredString('mediaId'), requiredString('targetLanguage')], 'MediaTranslationResult'),
method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'),
@@ -257,6 +268,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
{ name: 'availableLanguages', type: 'string[]', required: true, description: 'Canonical language plus all available translation language codes for this post.' },
],
},
{
@@ -437,6 +449,33 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' },
],
},
{
name: 'PostTranslationData',
description: 'Stored translation draft or published translation for a post.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Translation identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project identifier.' },
{ name: 'translationFor', type: 'string', required: true, description: 'Source post identifier this translation belongs to.' },
{ name: 'language', type: 'string', required: true, description: 'Target language code for the translation.' },
{ name: 'title', type: 'string', required: true, description: 'Translated title.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Translated excerpt.' },
{ name: 'content', type: 'string', required: true, description: 'Translated Markdown content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Translation lifecycle state.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp.' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp.' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publish timestamp when the translation is published.' },
{ name: 'filePath', type: 'string', required: true, description: 'Translation file path on disk.' },
],
},
{
name: 'PostTranslationResult',
description: 'Result from AI post translation containing the saved translation draft.',
fields: [
{ name: 'success', type: 'boolean', required: true, description: 'Whether the translation succeeded.' },
{ name: 'translation', type: 'PostTranslationData', required: false, description: 'Saved translation draft when successful.' },
{ name: 'error', type: 'string', required: false, description: 'Error message when translation failed.' },
],
},
{
name: 'SimilarPost',
description: 'A post with its semantic similarity score relative to a reference post.',
@@ -465,7 +504,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.13.0',
version: '1.15.0',
generatedAt: '2026-03-07T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,

View File

@@ -4,6 +4,7 @@ import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils';
import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { persistTranslationValidationReport } from './navigation/translationValidationPersistence';
import { persistDuplicatesResult } from './navigation/duplicatesPersistence';
import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
@@ -416,7 +417,9 @@ const App: React.FC = () => {
window.electronAPI?.posts.rebuildFromFiles(),
window.electronAPI?.media.rebuildFromFiles(),
window.electronAPI?.scripts.rebuildFromFiles(),
window.electronAPI?.templates.rebuildFromFiles(),
]);
await window.electronAPI?.posts.rebuildLinks();
await window.electronAPI?.media.regenerateMissingThumbnails();
} catch (error) {
console.error('Database rebuild failed:', error);
@@ -508,6 +511,49 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:validateTranslations', () => {
const validateAndOpen = async () => {
try {
const report = await window.electronAPI?.blog.validateTranslations();
const projectId = useAppStore.getState().activeProject?.id;
if (projectId && report) {
persistTranslationValidationReport(projectId, report);
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
detail: { projectId },
}));
}
openSingletonToolTab(openTab, 'translation-validation');
} catch (error) {
console.error('Translation validation failed:', error);
showToast.error(tr('translationValidation.error.validate'));
}
};
void validateAndOpen();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:fillMissingTranslations', () => {
const fillMissing = async () => {
try {
const result = await window.electronAPI?.blog.fillMissingTranslations();
if (result) {
if (!result.taskStarted) {
showToast.info(tr('blog.fillMissing.nothingToDo'));
} else {
showToast.success(tr('blog.fillMissing.started'));
}
}
} catch (error) {
console.error('Fill missing translations failed:', error);
showToast.error(tr('blog.fillMissing.error'));
}
};
void fillMissing();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:previewPost', async () => {
try {

View File

@@ -115,11 +115,16 @@
gap: 16px;
}
.metadata-toggle-header {
display: flex;
align-items: center;
gap: 8px;
}
.metadata-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 4px;
background: none;
border: none;
@@ -130,6 +135,7 @@
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
flex-shrink: 0;
}
.metadata-toggle:hover {
@@ -202,10 +208,12 @@
display: flex;
gap: 6px;
align-items: center;
flex-wrap: nowrap;
}
.editor-language-row select {
flex: 1;
min-width: 0;
}
.editor-language-row button.compact {
@@ -215,6 +223,53 @@
line-height: 1;
}
.editor-translations-flags {
display: flex;
gap: 4px;
align-items: center;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.editor-translation-flag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.editor-translation-flag.status-published {
opacity: 1;
}
.editor-translation-flag.status-draft {
opacity: 0.82;
}
.editor-translation-flag.status-archived {
opacity: 0.45;
filter: grayscale(0.35);
}
.editor-translation-flag.active {
border-color: var(--vscode-testing-iconQueued, #cca700);
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
}
.editor-translation-flag:hover {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
}
.editor-body {
flex: 1;
display: flex;
@@ -1037,6 +1092,11 @@
overflow: hidden;
}
.quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
.quick-action-item {
display: flex;
align-items: flex-start;
@@ -1081,3 +1141,115 @@
font-size: 11px;
opacity: 0.7;
}
.translation-modal-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
}
.translation-modal {
width: min(460px, calc(100vw - 32px));
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
.translation-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.translation-modal-header h2 {
margin: 0;
font-size: 16px;
}
.translation-modal-close {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
font-size: 20px;
line-height: 1;
padding: 4px 6px;
}
.translation-modal-close:hover {
color: var(--vscode-foreground);
}
.translation-modal-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
}
.translation-modal-copy {
margin: 0;
font-size: 13px;
color: var(--vscode-descriptionForeground);
}
.translation-modal-label {
font-size: 12px;
font-weight: 600;
}
.translation-modal-select {
width: 100%;
}
.translation-modal-status-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editorWidget-background) 88%, transparent);
border: 1px solid var(--vscode-panel-border);
}
.translation-modal-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 16px;
}
.translation-modal-status-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.translation-modal-status-copy strong {
font-size: 13px;
}
.translation-modal-status-copy small {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.translation-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 18px 18px;
border-top: 1px solid var(--vscode-panel-border);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,863 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { AISuggestionsModal } from '../AISuggestionsModal/AISuggestionsModal';
import { openEntityTab } from '../../navigation/tabPolicy';
import { useI18n } from '../../i18n';
import { SUPPORTED_POST_LANGUAGES, POST_LANGUAGE_FLAGS } from '../../../main/shared/i18n';
import { getMediaDisplayName } from './editorUtils';
export const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { t: tr } = useI18n();
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
const item = media.find(m => m.id === mediaId);
const [title, setTitle] = useState(item?.title || '');
const [alt, setAlt] = useState(item?.alt || '');
const [caption, setCaption] = useState(item?.caption || '');
const [author, setAuthor] = useState(item?.author || '');
const [tags, setTags] = useState(item?.tags.join(', ') || '');
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
const [postTitles, setPostTitles] = useState<Map<string, string>>(new Map());
const [showPostPicker, setShowPostPicker] = useState(false);
const [postSearchQuery, setPostSearchQuery] = useState('');
const [pickerPosts, setPickerPosts] = useState<{ id: string; title: string }[]>([]);
// Quick action menu state
const [showQuickActions, setShowQuickActions] = useState(false);
const [projectLanguage, setProjectLanguage] = useState('en');
const quickActionsRef = useRef<HTMLDivElement>(null);
// AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiSuggestionFields, setAISuggestionFields] = useState<Array<{ key: string; label: string; currentValue: string; suggestedValue?: string }>>([]);
const [aiError, setAIError] = useState<string | undefined>(undefined);
// Translation state
const [mediaLanguage, setMediaLanguage] = useState(item?.language || '');
const [mediaTranslations, setMediaTranslations] = useState<import('../../../main/shared/electronApi').MediaTranslationData[]>([]);
const [isTranslating, setIsTranslating] = useState(false);
const [isDetectingLanguage, setIsDetectingLanguage] = useState(false);
const [showMediaTranslationModal, setShowMediaTranslationModal] = useState(false);
const [translationTargetLanguage, setTranslationTargetLanguage] = useState('');
const [editingTranslation, setEditingTranslation] = useState<{ language: string; title: string; alt: string; caption: string } | null>(null);
// Load project language setting
useEffect(() => {
if (!activeProjectId) return;
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
if (metadata?.mainLanguage) {
setProjectLanguage(metadata.mainLanguage);
}
});
}, [activeProjectId]);
// Load media translations
const loadMediaTranslations = useCallback(async () => {
if (!mediaId) return;
const result = await window.electronAPI?.media.getTranslations?.(mediaId);
setMediaTranslations(result || []);
}, [mediaId]);
useEffect(() => {
loadMediaTranslations();
}, [loadMediaTranslations]);
// Handle language change on canonical media
const handleLanguageChange = async (newLanguage: string) => {
setMediaLanguage(newLanguage);
try {
const updated = await window.electronAPI?.media.update(item!.id, { language: newLanguage || undefined });
if (updated) {
updateMedia(item!.id, updated as Partial<typeof item>);
}
} catch (error) {
console.error('Failed to update media language:', error);
}
};
// Detect media language from metadata
const handleDetectLanguage = async () => {
if (!item || isDetectingLanguage) return;
setIsDetectingLanguage(true);
try {
const result = await window.electronAPI?.chat.detectMediaLanguage(
title || item.title || '',
alt || item.alt || '',
caption || item.caption || '',
);
if (result?.success && result.language) {
setMediaLanguage(result.language);
const updated = await window.electronAPI?.media.update(item.id, { language: result.language });
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
}
showToast.success(tr('editor.media.toast.languageDetected', { language: tr(`language.${result.language}`) }));
} else {
showToast.error(result?.error || tr('editor.media.error.detectLanguage'));
}
} catch (error) {
console.error('Failed to detect media language:', error);
showToast.error(tr('editor.media.error.detectLanguage'));
} finally {
setIsDetectingLanguage(false);
}
};
// Translate media metadata with AI
const handleTranslateMedia = async (targetLanguage: string) => {
if (!item || isTranslating) return;
setIsTranslating(true);
try {
const result = await window.electronAPI?.chat.translateMediaMetadata(item.id, targetLanguage);
if (result?.success) {
await loadMediaTranslations();
showToast.success(tr('editor.media.translations.translateSuccess', { language: tr(`language.${targetLanguage}`) }));
} else {
showToast.error(result?.error || tr('editor.media.translations.translateFailed'));
}
} catch (error) {
console.error('Failed to translate media metadata:', error);
showToast.error(tr('editor.media.translations.translateFailed'));
} finally {
setIsTranslating(false);
}
};
// Open translation modal (like posts)
const handleOpenMediaTranslationModal = () => {
const preferred = translationTargetLanguage
|| availableTranslationLanguages[0]
|| '';
setShowQuickActions(false);
setTranslationTargetLanguage(preferred);
setShowMediaTranslationModal(true);
};
const handleCloseMediaTranslationModal = () => {
setShowMediaTranslationModal(false);
};
const handleConfirmMediaTranslation = () => {
if (!translationTargetLanguage) return;
setShowMediaTranslationModal(false);
void handleTranslateMedia(translationTargetLanguage);
};
// Open edit modal for an existing translation
const handleOpenEditTranslation = (translation: import('../../../main/shared/electronApi').MediaTranslationData) => {
setEditingTranslation({
language: translation.language,
title: translation.title || '',
alt: translation.alt || '',
caption: translation.caption || '',
});
};
// Save edits to a translation
const handleSaveEditTranslation = async () => {
if (!item || !editingTranslation) return;
try {
await window.electronAPI?.media.upsertTranslation(item.id, editingTranslation.language, {
title: editingTranslation.title || undefined,
alt: editingTranslation.alt || undefined,
caption: editingTranslation.caption || undefined,
});
await loadMediaTranslations();
setEditingTranslation(null);
showToast.success(tr('editor.media.translations.saved', { language: tr(`language.${editingTranslation.language}`) }));
} catch (error) {
console.error('Failed to save media translation:', error);
showToast.error(tr('editor.media.translations.saveFailed'));
}
};
// Delete a media translation
const handleDeleteTranslation = async (language: string) => {
if (!item) return;
try {
await window.electronAPI?.media.deleteTranslation?.(item.id, language);
await loadMediaTranslations();
showToast.success(tr('editor.media.translations.deleted', { language: tr(`language.${language}`) }));
} catch (error) {
console.error('Failed to delete media translation:', error);
showToast.error(tr('editor.media.translations.deleteFailed'));
}
};
// Available languages for translation (exclude canonical)
const availableTranslationLanguages = SUPPORTED_POST_LANGUAGES.filter(
lang => lang !== mediaLanguage && !mediaTranslations.find(t => t.language === lang)
);
// Close quick actions menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (quickActionsRef.current && !quickActionsRef.current.contains(event.target as Node)) {
setShowQuickActions(false);
}
};
if (showQuickActions) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showQuickActions]);
// Handle AI image analysis for alt text and caption
const handleAIAnalysis = async () => {
if (!item || isAnalyzing) return;
setShowQuickActions(false);
setShowAISuggestionsModal(true);
setIsAnalyzing(true);
setAISuggestionFields([]);
setAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
setAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
{ key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
]);
} else {
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
}
} catch (error) {
console.error('Failed to analyze image:', error);
setAIError((error as Error).message || tr('editor.media.error.analyzeImage'));
} finally {
setIsAnalyzing(false);
}
};
// Handle applying AI suggestions
const handleApplyAISuggestions = (values: Record<string, string>) => {
if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption);
setShowAISuggestionsModal(false);
if (Object.keys(values).length > 0) {
showToast.success(tr('editor.media.toast.aiApplied'));
}
};
// Close AI suggestions modal
const handleCloseAISuggestionsModal = () => {
setShowAISuggestionsModal(false);
setAISuggestions(null);
setAIError(undefined);
};
// Load linked posts for this media and fetch their titles
useEffect(() => {
const loadLinkedPosts = async () => {
if (!mediaId || !activeProjectId) return;
try {
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
if (links) {
setLinkedPosts(links.map(l => ({ postId: l.postId, sortOrder: l.sortOrder })));
// Fetch titles for linked posts
const titles = new Map<string, string>();
for (const link of links) {
const post = await window.electronAPI?.posts.get(link.postId);
if (post) {
titles.set(link.postId, post.title || tr('editor.untitled'));
}
}
setPostTitles(titles);
}
} catch (error) {
console.error('Failed to load linked posts:', error);
}
};
loadLinkedPosts();
}, [mediaId, activeProjectId]);
// Fetch posts for the picker when it opens
useEffect(() => {
if (!showPostPicker) return;
const loadPickerPosts = async () => {
try {
const result = await window.electronAPI?.posts.getAll({ limit: 100, offset: 0 });
if (result?.items) {
setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || tr('editor.untitled') })));
}
} catch (error) {
console.error('Failed to load posts for picker:', error);
}
};
loadPickerPosts();
}, [showPostPicker]);
// Get post titles for display
const getPostTitle = (postId: string): string => {
return postTitles.get(postId) || tr('sidebar.loading');
};
// Handle linking to a new post
const handleLinkToPost = async (postId: string, postTitle: string) => {
try {
await window.electronAPI?.postMedia.link(postId, mediaId);
setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]);
setPostTitles(prev => new Map(prev).set(postId, postTitle));
setShowPostPicker(false);
setPostSearchQuery('');
showToast.success(tr('editor.media.toast.linkedToPost'));
} catch (error) {
console.error('Failed to link to post:', error);
showToast.error(tr('editor.media.toast.linkFailed'));
}
};
// Handle unlinking from a post
const handleUnlinkFromPost = async (postId: string) => {
try {
await window.electronAPI?.postMedia.unlink(postId, mediaId);
setLinkedPosts(linkedPosts.filter(l => l.postId !== postId));
showToast.success(tr('editor.media.toast.unlinkedFromPost'));
} catch (error) {
console.error('Failed to unlink from post:', error);
showToast.error(tr('editor.media.toast.unlinkFailed'));
}
};
// Handle click on a post to navigate to it
const handlePostClick = (postId: string) => {
openEntityTab(openTab, 'post', postId, 'preview');
};
// Get unlinked posts for picker, filtered by search
const unlinkedPosts = pickerPosts.filter(
p => !linkedPosts.find(l => l.postId === p.id)
).filter(
p => !postSearchQuery || p.title.toLowerCase().includes(postSearchQuery.toLowerCase())
);
useEffect(() => {
if (item) {
setTitle(item.title || '');
setAlt(item.alt || '');
setCaption(item.caption || '');
setAuthor(item.author || '');
setTags(item.tags.join(', '));
setMediaLanguage(item.language || '');
}
}, [item?.id]);
if (!item) {
return <div className="editor-empty">{tr('editor.media.notFound')}</div>;
}
const handleSave = async () => {
try {
const updated = await window.electronAPI?.media.update(item.id, {
title,
alt,
caption,
author: author || undefined,
language: mediaLanguage || undefined,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success(tr('editor.media.toast.updated'));
}
} catch (error) {
console.error('Failed to update media:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.media.error.updateTitle'),
message: err.message || tr('editor.media.error.updateMessage'),
stack: err.stack,
});
}
};
const handleReplaceFile = async () => {
try {
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success(tr('editor.media.toast.fileReplaced'));
}
// null means user cancelled or file unchanged - no action needed
} catch (error) {
console.error('Failed to replace media file:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.media.error.replaceTitle'),
message: err.message || tr('editor.media.error.replaceMessage'),
stack: err.stack,
});
}
};
const handleDelete = async () => {
try {
// Fetch posts that link to this media
const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId);
// Build references array
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
// Add posts that use this media - fetch titles from database
if (linkedPostsList && linkedPostsList.length > 0) {
for (const link of linkedPostsList) {
const post = await window.electronAPI?.posts.get(link.postId);
if (post) {
references.push({
id: post.id,
title: post.title || tr('editor.untitled'),
type: 'post',
});
}
}
}
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'media',
itemTitle: getMediaDisplayName(item),
references,
onConfirm: async () => {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
showToast.success(tr('editor.media.toast.deleted'));
} catch (error) {
console.error('Failed to delete media:', error);
const err = error as Error;
showErrorModal({
title: tr('editor.error.deleteTitle'),
message: err.message || tr('editor.media.error.deleteMessage'),
stack: err.stack,
});
}
},
});
} catch (error) {
console.error('Failed to fetch media references:', error);
const err = error as Error;
showErrorModal({
title: tr('errorModal.error'),
message: err.message || tr('editor.media.error.fetchReferencesMessage'),
stack: err.stack,
});
}
};
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{getMediaDisplayName(item)}</span>
</div>
</div>
<div className="editor-actions">
{/* Quick Actions Dropdown */}
<div className="quick-actions-wrapper" ref={quickActionsRef}>
<button
className="secondary quick-actions-btn"
onClick={() => setShowQuickActions(!showQuickActions)}
disabled={isAnalyzing || isDetectingLanguage || isTranslating}
title={tr('editor.media.quickActions.title')}
>
{(isAnalyzing || isDetectingLanguage || isTranslating) ? tr('editor.media.quickActions.analyzing') : tr('editor.media.quickActions.button')}
</button>
{showQuickActions && (
<div className="quick-actions-menu">
{item.mimeType.startsWith('image/') && (
<button
className="quick-action-item"
onClick={handleAIAnalysis}
disabled={isAnalyzing}
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.aiTitle')}</strong>
<small>{tr('editor.media.quickActions.aiDescription')}</small>
</span>
</button>
)}
{item.mimeType.startsWith('image/') && <div className="quick-actions-divider" />}
<button
className="quick-action-item"
onClick={() => { setShowQuickActions(false); void handleDetectLanguage(); }}
disabled={isDetectingLanguage || (!title && !alt && !caption)}
>
<span className="quick-action-icon">🔍</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.detectLanguageTitle')}</strong>
<small>{tr('editor.media.quickActions.detectLanguageDescription')}</small>
</span>
</button>
<div className="quick-actions-divider" />
<button
className="quick-action-item"
onClick={handleOpenMediaTranslationModal}
disabled={isTranslating || !mediaLanguage || availableTranslationLanguages.length === 0}
>
<span className="quick-action-icon">🌍</span>
<span className="quick-action-text">
<strong>{tr('editor.media.quickActions.translateTitle')}</strong>
<small>{tr('editor.media.quickActions.translateDescription')}</small>
</span>
</button>
</div>
)}
</div>
<button onClick={handleReplaceFile} className="secondary">{tr('editor.media.replaceFile')}</button>
<button onClick={handleSave}>{tr('common.save')}</button>
<button onClick={handleDelete} className="secondary danger">{tr('editor.delete')}</button>
</div>
</div>
<div className="editor-content media-editor">
<div className="media-preview">
{item.mimeType.startsWith('image/') ? (
<div className="media-preview-image">
<img
src={`bds-media://${item.id}?t=${item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt}`}
alt={item.alt || item.originalName}
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.parentElement?.classList.add('has-error');
}}
/>
</div>
) : (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
<span>{item.originalName}</span>
</div>
)}
</div>
<div className="media-details">
<div className="editor-field">
<label>{tr('editor.media.field.fileName')}</label>
<input type="text" value={item.originalName} disabled className="disabled" />
</div>
<div className="editor-field">
<label>{tr('editor.media.field.type')}</label>
<input type="text" value={item.mimeType} disabled className="disabled" />
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>{tr('editor.media.field.size')}</label>
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
</div>
{item.width && item.height && (
<div className="editor-field">
<label>{tr('editor.media.field.dimensions')}</label>
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
</div>
)}
</div>
<div className="editor-field">
<label>{tr('editor.media.field.title')}</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={tr('editor.media.placeholder.title')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.altText')}</label>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder={tr('editor.media.placeholder.altText')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.caption')}</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder={tr('editor.media.placeholder.caption')}
rows={3}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.tags')}</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={tr('editor.media.placeholder.tags')}
/>
</div>
<div className="editor-field">
<label>{tr('editor.media.field.author')}</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder={tr('editor.media.placeholder.author')}
/>
</div>
{/* Language & Translations Section */}
<div className="editor-field">
<label>{tr('editor.media.field.language')}</label>
<select
value={mediaLanguage}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<option value="">{tr('editor.media.field.languageNone')}</option>
{SUPPORTED_POST_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>{tr(`language.${lang}`)}</option>
))}
</select>
</div>
{mediaLanguage && (
<div className="editor-field media-translations-section">
<label>{tr('editor.media.translations.title')}</label>
{mediaTranslations.length === 0 ? (
<div className="no-linked-posts">{tr('editor.media.translations.none')}</div>
) : (
<div className="linked-posts-list">
{mediaTranslations.map((translation) => (
<div key={translation.language} className="linked-post-item">
<span
className="linked-post-title"
style={{ cursor: 'pointer' }}
onClick={() => handleOpenEditTranslation(translation)}
title={tr('editor.media.translations.editTitle', { language: tr(`language.${translation.language}`) })}
>
{POST_LANGUAGE_FLAGS[translation.language as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}{' '}
{tr(`language.${translation.language}`)}
{translation.title && `${translation.title}`}
</span>
<button
className="secondary"
onClick={() => handleTranslateMedia(translation.language)}
disabled={isTranslating}
title={tr('editor.media.translations.refreshTitle')}
style={{ marginRight: '4px', fontSize: '0.8em', padding: '2px 6px' }}
>
{tr('editor.media.translations.refresh')}
</button>
<button
className="unlink-btn"
onClick={() => handleDeleteTranslation(translation.language)}
title={tr('editor.media.translations.deleteTitle')}
>
×
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Linked Posts Section */}
<div className="editor-field linked-posts-section">
<label>
{tr('editor.media.linkedPosts')}
<button
className="add-link-btn"
onClick={() => setShowPostPicker(!showPostPicker)}
title={tr('editor.media.linkToPostTitle')}
>
{tr('editor.media.linkAction')}
</button>
</label>
{showPostPicker && (
<div className="post-picker">
<div className="post-picker-search">
<input
type="text"
placeholder={tr('editor.media.searchPosts')}
value={postSearchQuery}
onChange={(e) => setPostSearchQuery(e.target.value)}
autoFocus
/>
</div>
{unlinkedPosts.length === 0 ? (
<div className="no-posts">{postSearchQuery ? tr('editor.media.noMatchingPosts') : tr('editor.media.noPostsToLink')}</div>
) : (
<div className="post-picker-list">
{unlinkedPosts.slice(0, 10).map(post => (
<div
key={post.id}
className="post-picker-item"
onClick={() => handleLinkToPost(post.id, post.title)}
>
{post.title}
</div>
))}
{unlinkedPosts.length > 10 && (
<div className="post-picker-more">
{tr('editor.media.morePosts', { count: unlinkedPosts.length - 10 })}
</div>
)}
</div>
)}
</div>
)}
{linkedPosts.length === 0 ? (
<div className="no-linked-posts">{tr('editor.media.notLinked')}</div>
) : (
<div className="linked-posts-list">
{linkedPosts.map(({ postId }) => (
<div key={postId} className="linked-post-item">
<span
className="linked-post-title"
onClick={() => handlePostClick(postId)}
title={tr('editor.media.openPost')}
>
📄 {getPostTitle(postId)}
</span>
<button
className="unlink-btn"
onClick={() => handleUnlinkFromPost(postId)}
title={tr('editor.media.unlinkFromPost')}
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* AI Suggestions Modal */}
<AISuggestionsModal
isOpen={showAISuggestionsModal}
isLoading={isAnalyzing}
fields={aiSuggestionFields}
modalTitle={tr('aiSuggestions.title')}
loadingText={tr('aiSuggestions.analyzing')}
emptyText={tr('aiSuggestions.empty')}
error={aiError}
onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal}
/>
{/* Translation Modal */}
{showMediaTranslationModal && (
<div className="translation-modal-backdrop" onClick={handleCloseMediaTranslationModal}>
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
<div className="translation-modal-header">
<h2>{tr('editor.media.translations.title')}</h2>
<button className="translation-modal-close" onClick={handleCloseMediaTranslationModal} title={tr('common.cancel')}>×</button>
</div>
<div className="translation-modal-body">
<label className="translation-modal-label" htmlFor="media-translation-target-language">{tr('editor.media.translations.selectTarget')}</label>
<p className="translation-modal-copy">{tr('editor.media.translations.currentLanguage', { language: tr(`language.${mediaLanguage}`) })}</p>
<select
id="media-translation-target-language"
className="translation-modal-select"
value={translationTargetLanguage}
onChange={(e) => setTranslationTargetLanguage(e.target.value)}
>
{SUPPORTED_POST_LANGUAGES
.filter(lang => lang !== mediaLanguage)
.map((lang) => {
const existing = mediaTranslations.find(t => t.language === lang);
return (
<option key={lang} value={lang}>
{tr(`language.${lang}`)}{existing ? ` (${tr('editor.media.translations.refresh')})` : ''}
</option>
);
})}
</select>
{translationTargetLanguage && (
<div className="translation-modal-status-row">
<span className="translation-modal-flag" aria-hidden="true">{POST_LANGUAGE_FLAGS[translationTargetLanguage as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}</span>
<span className="translation-modal-status-copy">
<strong>{tr(`language.${translationTargetLanguage}`)}</strong>
<small>
{mediaTranslations.find(t => t.language === translationTargetLanguage)
? tr('editor.media.translations.refresh')
: tr('editor.media.translations.none')}
</small>
</span>
</div>
)}
</div>
<div className="translation-modal-footer">
<button className="secondary" onClick={handleCloseMediaTranslationModal}>{tr('common.cancel')}</button>
<button
onClick={handleConfirmMediaTranslation}
disabled={!translationTargetLanguage || isTranslating}
title={tr('editor.media.quickActions.translateDescription')}
>
{isTranslating ? tr('editor.media.translations.translating') : tr('editor.media.translations.translateButton')}
</button>
</div>
</div>
</div>
)}
{/* Edit Translation Modal */}
{editingTranslation && (
<div className="translation-modal-backdrop" onClick={() => setEditingTranslation(null)}>
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
<div className="translation-modal-header">
<h2>{tr('editor.media.translations.editTitle', { language: tr(`language.${editingTranslation.language}`) })}</h2>
<button className="translation-modal-close" onClick={() => setEditingTranslation(null)} title={tr('common.cancel')}>×</button>
</div>
<div className="translation-modal-body">
<div className="editor-field">
<label htmlFor="edit-translation-title">{tr('editor.media.field.title')}</label>
<input
id="edit-translation-title"
type="text"
value={editingTranslation.title}
onChange={(e) => setEditingTranslation({ ...editingTranslation, title: e.target.value })}
placeholder={tr('editor.media.placeholder.title')}
/>
</div>
<div className="editor-field">
<label htmlFor="edit-translation-alt">{tr('editor.media.field.altText')}</label>
<input
id="edit-translation-alt"
type="text"
value={editingTranslation.alt}
onChange={(e) => setEditingTranslation({ ...editingTranslation, alt: e.target.value })}
placeholder={tr('editor.media.placeholder.altText')}
/>
</div>
<div className="editor-field">
<label htmlFor="edit-translation-caption">{tr('editor.media.field.caption')}</label>
<textarea
id="edit-translation-caption"
value={editingTranslation.caption}
onChange={(e) => setEditingTranslation({ ...editingTranslation, caption: e.target.value })}
placeholder={tr('editor.media.placeholder.caption')}
rows={3}
/>
</div>
</div>
<div className="translation-modal-footer">
<button className="secondary" onClick={() => setEditingTranslation(null)}>{tr('common.cancel')}</button>
<button onClick={() => void handleSaveEditTranslation()}>{tr('common.save')}</button>
</div>
</div>
</div>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
export const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
it: 'it-IT',
es: 'es-ES',
};
/** Get display name for media: prefer title over originalName */
export function getMediaDisplayName(media: { title?: string; originalName: string }): string {
return media.title || media.originalName;
}

View File

@@ -223,6 +223,7 @@ export const SettingsView: React.FC = () => {
const [defaultProjectPath, setDefaultProjectPath] = useState('');
const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en');
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
const [projectBlogLanguages, setProjectBlogLanguages] = useState<string[]>([]);
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker');
@@ -318,6 +319,11 @@ export const SettingsView: React.FC = () => {
const incomingSemanticSimilarity = (metadata as { semanticSimilarityEnabled?: unknown } | null)?.semanticSimilarityEnabled;
setSemanticSimilarityEnabled(incomingSemanticSimilarity === true);
const incomingBlogLanguages = (metadata as { blogLanguages?: unknown } | null)?.blogLanguages;
setProjectBlogLanguages(Array.isArray(incomingBlogLanguages)
? incomingBlogLanguages.filter((l): l is string => typeof l === 'string')
: []);
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
setCategoryMetadata((current) => {
@@ -550,6 +556,7 @@ export const SettingsView: React.FC = () => {
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
pythonRuntimeMode: projectPythonRuntimeMode,
semanticSimilarityEnabled,
blogLanguages: projectBlogLanguages.length > 0 ? projectBlogLanguages : undefined,
categoryMetadata,
});
}
@@ -691,6 +698,37 @@ export const SettingsView: React.FC = () => {
</select>
</SettingRow>
<SettingRow
id="project-blog-languages"
label={t('settings.project.blogLanguagesLabel')}
description={t('settings.project.blogLanguagesDescription')}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{SUPPORTED_RENDER_LANGUAGES.map((language) => {
const isMain = language === projectMainLanguage;
const isChecked = isMain || projectBlogLanguages.includes(language);
return (
<label key={language} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', opacity: isMain ? 0.7 : 1 }}>
<input
type="checkbox"
checked={isChecked}
disabled={isMain}
onChange={(e) => {
if (isMain) return;
setProjectBlogLanguages((prev) =>
e.target.checked
? [...prev.filter((l) => l !== language), language]
: prev.filter((l) => l !== language),
);
}}
/>
{t(RENDER_LANGUAGE_LABEL_KEY[language])}
</label>
);
})}
</div>
</SettingRow>
<SettingRow
id="project-author"
label={t('settings.project.defaultAuthorLabel')}

View File

@@ -125,6 +125,24 @@
text-overflow: ellipsis;
}
.sidebar-item-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-item-language-badge {
flex-shrink: 0;
min-width: 18px;
padding: 1px 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--vscode-badge-background) 82%, transparent);
color: var(--vscode-badge-foreground);
font-size: 10px;
font-weight: 700;
text-align: center;
}
.sidebar-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);

View File

@@ -736,6 +736,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
await createAndFocusPost({
createPost: async (input) => (await window.electronAPI?.posts.create(input)) as { id: string } | null | undefined,
setSelectedPost: selectPost,
categories: isPagesMode ? [PAGE_CATEGORY] : [],
onError: (error) => {
console.error('Failed to create post:', error);
},
@@ -876,7 +877,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>
@@ -904,7 +912,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt, uiLocale)}</div>
</div>
</div>
@@ -932,7 +947,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-title-row">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
{post.availableLanguages?.length > 1 && (
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
{post.availableLanguages.length}
</span>
)}
</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>

View File

@@ -87,6 +87,10 @@ const getTabTitle = (
return tr('siteValidation.tabTitle');
}
if (tab.type === 'translation-validation') {
return tr('translationValidation.tabTitle');
}
if (tab.type === 'find-duplicates') {
return tr('duplicatesView.tabTitle');
}
@@ -184,6 +188,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
</svg>
);
case 'translation-validation':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2.5A1.5 1.5 0 0 1 3.5 1h5A1.5 1.5 0 0 1 10 2.5v1h2.5A1.5 1.5 0 0 1 14 5v7.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-10zM3.5 2a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H10v1.15a.5.5 0 0 1-.85.35L7.5 4.35 5.85 6A.5.5 0 0 1 5 5.65V2.5a.5.5 0 0 0-.5-.5h-1zm2.5 1.71v.73l1.15-1.14a.5.5 0 0 1 .7 0L9 4.44v-.73a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 0-.5.5zM5.5 8h5v1h-5V8zm0 2h5v1h-5v-1z"/>
</svg>
);
case 'find-duplicates':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -0,0 +1,103 @@
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow: auto;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
}
.translation-validation-summary h2 {
margin: 0 0 8px 0;
font-size: 1.1rem;
}
.translation-validation-summary p {
margin: 0;
color: var(--vscode-descriptionForeground);
}
.translation-validation-section h3 {
margin: 0 0 8px 0;
font-size: 1rem;
}
.translation-validation-empty,
.translation-validation-status {
margin: 0;
color: var(--vscode-descriptionForeground);
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.translation-validation-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 12px;
background: color-mix(in srgb, var(--vscode-editor-background) 82%, var(--vscode-editorWidget-background) 18%);
}
.translation-validation-card.translation-validation-card-db {
border-left: 4px solid var(--vscode-notificationsWarningIcon-foreground);
}
.translation-validation-card.translation-validation-card-file {
border-left: 4px solid var(--vscode-notificationsErrorIcon-foreground);
}
.translation-validation-card-title {
margin: 0 0 6px 0;
font-weight: 600;
}
.translation-validation-card-meta {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 10px;
font-size: 12px;
}
.translation-validation-actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.translation-validation-revalidate,
.translation-validation-fix {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.translation-validation-revalidate:hover:not(:disabled),
.translation-validation-fix:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.translation-validation-revalidate:disabled,
.translation-validation-fix:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
}
.translation-validation-card-meta dd {
margin: 0;
word-break: break-word;
font-family: var(--vscode-editor-font-family);
}

View File

@@ -0,0 +1,240 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { TranslationValidationIssue, TranslationValidationReport, TranslationValidationFixResult } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import { getPersistedTranslationValidationReport, persistTranslationValidationReport } from '../../navigation/translationValidationPersistence';
import './TranslationValidationView.css';
function getIssueLabel(
issue: TranslationValidationIssue['issue'],
tr: (key: string, vars?: Record<string, string | number>) => string,
): string {
if (issue === 'same-language-as-canonical') {
return tr('translationValidation.issue.sameLanguage');
}
if (issue === 'do-not-translate-has-translations') {
return tr('translationValidation.issue.doNotTranslate');
}
if (issue === 'content-in-database') {
return tr('translationValidation.issue.contentInDatabase');
}
return tr('translationValidation.issue.missingSource');
}
function ValidationIssueCard({
issue,
kind,
}: {
issue: TranslationValidationIssue;
kind: 'db' | 'file';
}): React.JSX.Element {
const { t: tr } = useI18n();
return (
<article className={`translation-validation-card translation-validation-card-${kind}`}>
<p className="translation-validation-card-title">{getIssueLabel(issue.issue, tr)}</p>
<dl className="translation-validation-card-meta">
<dt>{tr('translationValidation.field.translationFor')}</dt>
<dd>{issue.translationFor}</dd>
{issue.translationId ? (
<>
<dt>{tr('translationValidation.field.translationId')}</dt>
<dd>{issue.translationId}</dd>
</>
) : null}
{issue.title ? (
<>
<dt>{tr('translationValidation.field.title')}</dt>
<dd>{issue.title}</dd>
</>
) : null}
<dt>{tr('translationValidation.field.languages')}</dt>
<dd>
{issue.canonicalLanguage
? tr('translationValidation.languagesWithCanonical', {
canonical: issue.canonicalLanguage,
translation: issue.translationLanguage,
})
: issue.translationLanguage}
</dd>
{issue.filePath ? (
<>
<dt>{tr('translationValidation.field.filePath')}</dt>
<dd>{issue.filePath}</dd>
</>
) : null}
</dl>
</article>
);
}
export const TranslationValidationView: React.FC = () => {
const { t: tr } = useI18n();
const { activeProject } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [isRevalidating, setIsRevalidating] = useState(false);
const [isFixing, setIsFixing] = useState(false);
const [report, setReport] = useState<TranslationValidationReport | null>(null);
const loadPersistedReport = () => {
setIsLoading(true);
try {
const projectId = activeProject?.id;
if (!projectId) {
setReport(null);
return;
}
setReport(getPersistedTranslationValidationReport(projectId));
} finally {
setIsLoading(false);
}
};
const handleRevalidate = async () => {
setIsRevalidating(true);
try {
const freshReport = await window.electronAPI?.blog.validateTranslations();
const projectId = activeProject?.id;
if (projectId && freshReport) {
persistTranslationValidationReport(projectId, freshReport);
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
detail: { projectId },
}));
}
} catch (error) {
console.error('Translation revalidation failed:', error);
showToast.error(tr('translationValidation.error.validate'));
} finally {
setIsRevalidating(false);
}
};
const handleFix = async () => {
if (!report) return;
setIsFixing(true);
try {
const result = await window.electronAPI.blog.fixInvalidTranslations(report) as TranslationValidationFixResult;
showToast.success(tr('translationValidation.toast.fixSuccess', {
dbRows: result.deletedDatabaseRows,
files: result.deletedFiles,
flushed: result.flushedTranslations,
}));
// Re-validate after fixing to refresh the report
await handleRevalidate();
} catch (error) {
console.error('Fixing invalid translations failed:', error);
showToast.error(tr('translationValidation.error.fix'));
} finally {
setIsFixing(false);
}
};
useEffect(() => {
loadPersistedReport();
}, [activeProject?.id]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (!activeProject?.id || detail?.projectId !== activeProject.id) {
return;
}
loadPersistedReport();
};
window.addEventListener('bds:translation-validation-updated', handler);
return () => window.removeEventListener('bds:translation-validation-updated', handler);
}, [activeProject?.id]);
const summary = useMemo(() => {
if (!report) {
return null;
}
return tr('translationValidation.summary', {
dbRows: report.checkedDatabaseRowCount,
files: report.checkedFilesystemFileCount,
invalidDb: report.invalidDatabaseRows.length,
invalidFiles: report.invalidFilesystemFiles.length,
});
}, [report, tr]);
const canFix = useMemo(() => {
if (!report) return false;
return report.invalidDatabaseRows.length > 0 || report.invalidFilesystemFiles.length > 0;
}, [report]);
if (isLoading) {
return (
<div className="translation-validation-view">
<p className="translation-validation-status">{tr('translationValidation.loading')}</p>
</div>
);
}
if (!report) {
return (
<div className="translation-validation-view">
<p className="translation-validation-status">{tr('translationValidation.empty')}</p>
</div>
);
}
return (
<div className="translation-validation-view">
<div className="translation-validation-summary">
<h2>{tr('translationValidation.title')}</h2>
<p>{summary}</p>
</div>
<section className="translation-validation-section">
<h3>{tr('translationValidation.databaseTitle')}</h3>
{report.invalidDatabaseRows.length === 0 ? (
<p className="translation-validation-empty">{tr('translationValidation.noneDatabase')}</p>
) : (
<div className="translation-validation-list">
{report.invalidDatabaseRows.map((issue, index) => (
<ValidationIssueCard key={`db:${issue.translationId || issue.translationFor}:${index}`} issue={issue} kind="db" />
))}
</div>
)}
</section>
<section className="translation-validation-section">
<h3>{tr('translationValidation.filesystemTitle')}</h3>
{report.invalidFilesystemFiles.length === 0 ? (
<p className="translation-validation-empty">{tr('translationValidation.noneFilesystem')}</p>
) : (
<div className="translation-validation-list">
{report.invalidFilesystemFiles.map((issue, index) => (
<ValidationIssueCard key={`file:${issue.filePath || issue.translationFor}:${index}`} issue={issue} kind="file" />
))}
</div>
)}
</section>
<div className="translation-validation-actions">
<button
type="button"
className="translation-validation-revalidate"
onClick={handleRevalidate}
disabled={isRevalidating || isFixing}
>
{isRevalidating ? tr('translationValidation.revalidating') : tr('translationValidation.revalidate')}
</button>
<button
type="button"
className="translation-validation-fix"
onClick={handleFix}
disabled={!canFix || isFixing || isRevalidating}
>
{isFixing ? tr('translationValidation.fixing') : tr('translationValidation.fix')}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { TranslationValidationView } from './TranslationValidationView';

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
"menu.item.validateTranslations": "Übersetzungen validieren",
"translationValidation.tabTitle": "Übersetzungsvalidierung",
"translationValidation.title": "Übersetzungen validieren",
"translationValidation.summary": "Geprüfte DB-Zeilen: {dbRows} · Geprüfte Dateien: {files} · Ungültige DB-Zeilen: {invalidDb} · Ungültige Dateien: {invalidFiles}",
"translationValidation.loading": "Übersetzungen werden validiert...",
"translationValidation.empty": "Führe Blog -> Übersetzungen validieren aus, um die Übersetzungsintegrität zu prüfen.",
"translationValidation.databaseTitle": "Ungültige Übersetzungszeilen in der Datenbank",
"translationValidation.filesystemTitle": "Ungültige Übersetzungsdateien auf dem Datenträger",
"translationValidation.noneDatabase": "Keine ungültigen Übersetzungszeilen gefunden.",
"translationValidation.noneFilesystem": "Keine ungültigen Übersetzungsdateien gefunden.",
"translationValidation.error.validate": "Übersetzungsvalidierung fehlgeschlagen",
"translationValidation.issue.sameLanguage": "Übersetzungssprache entspricht der kanonischen Beitragssprache",
"translationValidation.issue.missingSource": "Übersetzung verweist auf einen fehlenden Quellbeitrag",
"translationValidation.issue.doNotTranslate": "Beitrag ist als nicht-übersetzen markiert, hat aber Übersetzungen",
"translationValidation.issue.contentInDatabase": "Veröffentlichte Übersetzung hat Inhalt in der DB statt im Dateisystem",
"translationValidation.field.translationFor": "Quellbeitrag",
"translationValidation.field.translationId": "Übersetzungszeile",
"translationValidation.field.title": "Titel",
"translationValidation.field.languages": "Sprachen",
"translationValidation.field.filePath": "Datei",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Erneut validieren",
"translationValidation.revalidating": "Wird validiert…",
"translationValidation.fix": "Probleme beheben",
"translationValidation.fixing": "Wird behoben…",
"translationValidation.toast.fixSuccess": "{dbRows} DB-Zeilen und {files} Dateien gelöscht, {flushed} Übersetzungen auf Disk geschrieben",
"translationValidation.error.fix": "Fehler beim Beheben ungültiger Übersetzungen",
"menuEditor.tabTitle": "Blog-Menü",
"menuEditor.title": "Blog-Menü-Editor",
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
@@ -419,18 +446,18 @@
"metadataDiff.orphanFiles.badge": "Verwaiste Datei",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Pfad",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D DB",
"metadataDiff.orphanFiles.importTitle": "Alle verwaisten Dateien in die Datenbank importieren",
"metadataDiff.orphanFiles.importing": "Importiere…",
"metadataDiff.orphanFiles.importSuccess": "{success} verwaiste Dateien importiert{failed}",
"metadataDiff.orphanFiles.importError": "Import der verwaisten Dateien fehlgeschlagen",
"metadataDiff.sync.failed": "fehlgeschlagen",
"metadataDiff.sync.dbToFile.title": "Dateien mit Datenbankwerten aktualisieren",
"metadataDiff.sync.dbToFile.short": "DB\u2192D",
"metadataDiff.sync.dbToFile.short": "DBD",
"metadataDiff.sync.dbToFile.success": "{success} Beiträge in Dateien synchronisiert{fehlgeschlagen}",
"metadataDiff.sync.dbToFile.error": "Synchronisierung in Dateien fehlgeschlagen",
"metadataDiff.sync.fileToDb.title": "Datenbank mit Dateiwerten aktualisieren",
"metadataDiff.sync.fileToDb.short": "D\u2192DB",
"metadataDiff.sync.fileToDb.short": "DDB",
"metadataDiff.sync.fileToDb.success": "{success} Dateien in die Datenbank synchronisiert{fehlgeschlagen}",
"metadataDiff.sync.fileToDb.error": "Synchronisierung in die Datenbank fehlgeschlagen",
"metadataDiff.value.database": "Datenbank",
@@ -461,6 +488,7 @@
"sidebar.published": "Veröffentlicht",
"sidebar.archived": "Archiviert",
"sidebar.untitled": "Ohne Titel",
"sidebar.languagesAvailable": "{count} Sprachen verfugbar",
"sidebar.noMatchingPosts": "Keine passenden Beiträge",
"sidebar.createFirstPost": "Ersten Beitrag erstellen",
"sidebar.loadMore": "Mehr laden ({loaded} von {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Hauptsprache",
"settings.project.mainLanguageDescription": "Die primäre Sprache für deine Blog-Inhalte. KI-generierte Titel, Alt-Texte und Bildunterschriften nutzen diese Sprache.",
"settings.project.blogLanguagesLabel": "Blog-Sprachen",
"settings.project.blogLanguagesDescription": "Sprachen, in denen der Blog gerendert wird. Die Hauptsprache ist immer enthalten. Zusätzliche Sprachen erzeugen übersetzte Unterbäume.",
"settings.project.defaultAuthorLabel": "Standardautor",
"settings.project.defaultAuthorDescription": "Der Standard-Autorname für neue Beiträge und Medien. Kann pro Element überschrieben werden.",
"settings.project.defaultAuthorPlaceholder": "Autorenname",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Beitragsvorschau",
"editor.previewLoading": "Vorschau wird geladen...",
"editor.metadata.toggle": "Metadaten",
"editor.translations.title": "Ubersetzungen",
"editor.translations.currentLanguage": "Aktuelle Sprache: {language}",
"editor.translations.none": "Noch keine Ubersetzungen.",
"editor.translations.selectTarget": "Zielsprache auswahlen",
"editor.translations.translateButton": "Ubersetzen nach...",
"editor.translations.translateTitle": "Ubersetzung per KI erstellen oder aktualisieren",
"editor.translations.translating": "Wird ubersetzt...",
"editor.translations.refresh": "Aktualisieren",
"editor.translations.refreshTitle": "Diese Ubersetzung per KI neu erzeugen",
"editor.translations.publish": "Veroffentlichen",
"editor.translations.publishTitle": "Diese Ubersetzung in ihre Markdown-Datei veroffentlichen",
"editor.translations.publishing": "Wird veroffentlicht...",
"editor.translations.missing": "Fehlend: {languages}",
"editor.translations.complete": "Alle unterstutzten Ubersetzungssprachen sind verfugbar.",
"editor.translations.translateSuccess": "Ubersetzung fur {language} aktualisiert",
"editor.translations.translateFailed": "Ubersetzung fehlgeschlagen",
"editor.translations.publishSuccess": "Ubersetzung fur {language} veroffentlicht",
"editor.translations.publishFailed": "Ubersetzung konnte nicht veroffentlicht werden",
"editor.translations.status.draft": "Entwurf",
"editor.translations.status.published": "Veroffentlicht",
"editor.translations.status.archived": "Archiviert",
"editor.excerpt.toggle": "Auszug",
"editor.footer.created": "Erstellt",
"editor.footer.updated": "Aktualisiert",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
"editor.media.quickActions.detectLanguageTitle": "Sprache erkennen",
"editor.media.quickActions.detectLanguageDescription": "Sprache aus Metadaten per KI erkennen",
"editor.media.quickActions.translateTitle": "Übersetzen in…",
"editor.media.quickActions.translateDescription": "Übersetzung per KI erstellen oder aktualisieren",
"editor.media.translations.currentLanguage": "Aktuelle Sprache: {language}",
"editor.media.translations.selectTarget": "Zielsprache wählen",
"editor.post.quickActions.title": "Schnellaktionen",
"editor.post.quickActions.analyzing": "⏳ Wird analysiert…",
"editor.post.quickActions.button": "⚡ Schnellaktionen",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Bildunterschrift",
"editor.media.field.tags": "Tags (kommagetrennt)",
"editor.media.field.author": "Autor",
"editor.media.field.language": "Sprache",
"editor.media.field.languageNone": "Nicht festgelegt",
"editor.media.translations.title": "Übersetzungen",
"editor.media.translations.none": "Noch keine Übersetzungen.",
"editor.media.translations.translateButton": "Übersetzen in…",
"editor.media.translations.translating": "Übersetze…",
"editor.media.translations.translateSuccess": "Übersetzung aktualisiert für {language}",
"editor.media.translations.translateFailed": "Übersetzung fehlgeschlagen",
"editor.media.translations.refresh": "Aktualisieren",
"editor.media.translations.refreshTitle": "Diese Übersetzung per KI neu generieren",
"editor.media.translations.deleteTitle": "Diese Übersetzung löschen",
"editor.media.translations.deleted": "Übersetzung gelöscht für {language}",
"editor.media.translations.deleteFailed": "Löschen der Übersetzung fehlgeschlagen",
"editor.media.translations.editTitle": "Übersetzung bearbeiten — {language}",
"editor.media.translations.saved": "Übersetzung gespeichert für {language}",
"editor.media.translations.saveFailed": "Speichern der Übersetzung fehlgeschlagen",
"editor.media.toast.languageDetected": "Sprache erkannt: {language}",
"editor.media.error.detectLanguage": "Spracherkennung fehlgeschlagen",
"editor.media.placeholder.title": "Titel für Listen und Suchergebnisse",
"editor.media.placeholder.altText": "Bild für Barrierefreiheit beschreiben",
"editor.media.placeholder.caption": "Bildunterschrift",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Verwendet in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} weitere",
"importAnalysis.noParameters": "(keine Parameter)",
"sidebar.nav.mcp": "MCP-Server",
"settings.mcp.title": "MCP-Server",
"settings.mcp.description": "Konfigurieren Sie den Model Context Protocol Server, der KI-Programmieragenten die Interaktion mit Ihrem Blog ermöglicht.",
"settings.mcp.statusLabel": "Serverstatus",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Alle auswählen",
"duplicatesView.uncheckAll": "Alle abwählen",
"duplicatesView.dismissChecked": "Ausgewählte ignorieren ({count})",
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie."
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie.",
"editor.doNotTranslateLabel": "Nicht übersetzen",
"blog.fillMissing.nothingToDo": "Alle Übersetzungen sind aktuell.",
"blog.fillMissing.started": "Übersetzungsaufgabe gestartet. Fortschritt im Aufgabenbereich.",
"blog.fillMissing.error": "Fehlende Übersetzungen konnten nicht erstellt werden."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Site validation failed",
"siteValidation.error.apply": "Applying validation failed",
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
"menu.item.validateTranslations": "Validate Translations",
"translationValidation.tabTitle": "Translation Validation",
"translationValidation.title": "Validate Translations",
"translationValidation.summary": "Checked DB rows: {dbRows} · Checked files: {files} · Invalid DB rows: {invalidDb} · Invalid files: {invalidFiles}",
"translationValidation.loading": "Validating translations...",
"translationValidation.empty": "Run Blog -> Validate Translations to inspect translation integrity.",
"translationValidation.databaseTitle": "Invalid database translation rows",
"translationValidation.filesystemTitle": "Invalid translation files on disk",
"translationValidation.noneDatabase": "No invalid translation rows found.",
"translationValidation.noneFilesystem": "No invalid translation files found.",
"translationValidation.error.validate": "Translation validation failed",
"translationValidation.issue.sameLanguage": "Translation language matches canonical post language",
"translationValidation.issue.missingSource": "Translation points to a missing source post",
"translationValidation.issue.doNotTranslate": "Post is marked as do-not-translate but has translations",
"translationValidation.issue.contentInDatabase": "Published translation has content stuck in DB instead of filesystem",
"translationValidation.field.translationFor": "Source post",
"translationValidation.field.translationId": "Translation row",
"translationValidation.field.title": "Title",
"translationValidation.field.languages": "Languages",
"translationValidation.field.filePath": "File",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalidate",
"translationValidation.revalidating": "Revalidating…",
"translationValidation.fix": "Fix Issues",
"translationValidation.fixing": "Fixing…",
"translationValidation.toast.fixSuccess": "Deleted {dbRows} DB rows and {files} files, flushed {flushed} translations to disk",
"translationValidation.error.fix": "Failed to fix invalid translations",
"menuEditor.tabTitle": "Blog Menu",
"menuEditor.title": "Blog Menu Editor",
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
@@ -412,11 +439,11 @@
"metadataDiff.fieldFilter.toggle": "Filter by {field}",
"metadataDiff.sync.failed": "failed",
"metadataDiff.sync.dbToFile.title": "Update files with database values",
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
"metadataDiff.sync.dbToFile.short": "DBF",
"metadataDiff.sync.dbToFile.success": "Synced {success} posts to files{failed}",
"metadataDiff.sync.dbToFile.error": "Failed to sync to files",
"metadataDiff.sync.fileToDb.title": "Update database with file values",
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
"metadataDiff.sync.fileToDb.short": "FDB",
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{failed}",
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
"metadataDiff.value.database": "Database",
@@ -433,7 +460,7 @@
"metadataDiff.orphanFiles.badge": "Orphan file",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Path",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D DB",
"metadataDiff.orphanFiles.importTitle": "Import all orphan files into the database",
"metadataDiff.orphanFiles.importing": "Importing…",
"metadataDiff.orphanFiles.importSuccess": "{success} orphan files imported{failed}",
@@ -461,6 +488,7 @@
"sidebar.published": "Published",
"sidebar.archived": "Archived",
"sidebar.untitled": "Untitled",
"sidebar.languagesAvailable": "{count} languages available",
"sidebar.noMatchingPosts": "No matching posts",
"sidebar.createFirstPost": "Create your first post",
"sidebar.loadMore": "Load more ({loaded} of {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Main Language",
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
"settings.project.blogLanguagesLabel": "Blog Languages",
"settings.project.blogLanguagesDescription": "Languages the blog is rendered in. The main language is always included. Additional languages generate translated subtrees.",
"settings.project.defaultAuthorLabel": "Default Author",
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
"settings.project.defaultAuthorPlaceholder": "Author Name",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Post preview",
"editor.previewLoading": "Loading preview...",
"editor.metadata.toggle": "Metadata",
"editor.translations.title": "Translations",
"editor.translations.currentLanguage": "Current language: {language}",
"editor.translations.none": "No translations yet.",
"editor.translations.selectTarget": "Select target language",
"editor.translations.translateButton": "Translate to...",
"editor.translations.translateTitle": "Create or refresh a translation using AI",
"editor.translations.translating": "Translating...",
"editor.translations.refresh": "Refresh",
"editor.translations.refreshTitle": "Regenerate this translation using AI",
"editor.translations.publish": "Publish",
"editor.translations.publishTitle": "Publish this translation to its markdown file",
"editor.translations.publishing": "Publishing...",
"editor.translations.missing": "Missing: {languages}",
"editor.translations.complete": "All supported translation languages are available.",
"editor.translations.translateSuccess": "Translation updated for {language}",
"editor.translations.translateFailed": "Translation failed",
"editor.translations.publishSuccess": "Published translation for {language}",
"editor.translations.publishFailed": "Publishing translation failed",
"editor.translations.status.draft": "Draft",
"editor.translations.status.published": "Published",
"editor.translations.status.archived": "Archived",
"editor.excerpt.toggle": "Excerpt",
"editor.footer.created": "Created",
"editor.footer.updated": "Updated",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
"editor.media.quickActions.detectLanguageTitle": "Detect Language",
"editor.media.quickActions.detectLanguageDescription": "Detect language from metadata using AI",
"editor.media.quickActions.translateTitle": "Translate to...",
"editor.media.quickActions.translateDescription": "Create or refresh a translation using AI",
"editor.media.translations.currentLanguage": "Current language: {language}",
"editor.media.translations.selectTarget": "Select target language",
"editor.post.quickActions.title": "Quick Actions",
"editor.post.quickActions.analyzing": "⏳ Analyzing…",
"editor.post.quickActions.button": "⚡ Quick Actions",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Caption",
"editor.media.field.tags": "Tags (comma-separated)",
"editor.media.field.author": "Author",
"editor.media.field.language": "Language",
"editor.media.field.languageNone": "Not set",
"editor.media.translations.title": "Translations",
"editor.media.translations.none": "No translations yet.",
"editor.media.translations.translateButton": "Translate to…",
"editor.media.translations.translating": "Translating…",
"editor.media.translations.translateSuccess": "Translation updated for {language}",
"editor.media.translations.translateFailed": "Translation failed",
"editor.media.translations.refresh": "Refresh",
"editor.media.translations.refreshTitle": "Regenerate this translation using AI",
"editor.media.translations.deleteTitle": "Delete this translation",
"editor.media.translations.deleted": "Translation deleted for {language}",
"editor.media.translations.deleteFailed": "Failed to delete translation",
"editor.media.translations.editTitle": "Edit Translation — {language}",
"editor.media.translations.saved": "Translation saved for {language}",
"editor.media.translations.saveFailed": "Failed to save translation",
"editor.media.toast.languageDetected": "Language detected: {language}",
"editor.media.error.detectLanguage": "Failed to detect language",
"editor.media.placeholder.title": "Title for lists and search results",
"editor.media.placeholder.altText": "Describe the image for accessibility",
"editor.media.placeholder.caption": "Image caption",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Used in: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} more",
"importAnalysis.noParameters": "(no parameters)",
"sidebar.nav.mcp": "MCP Server",
"settings.mcp.title": "MCP Server",
"settings.mcp.description": "Configure the Model Context Protocol server that allows AI coding agents to interact with your blog.",
"settings.mcp.statusLabel": "Server Status",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Check All",
"duplicatesView.uncheckAll": "Uncheck All",
"duplicatesView.dismissChecked": "Dismiss Checked ({count})",
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology."
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology.",
"editor.doNotTranslateLabel": "Do not translate",
"blog.fillMissing.nothingToDo": "All translations are up to date.",
"blog.fillMissing.started": "Translation task started. Check the task panel for progress.",
"blog.fillMissing.error": "Failed to fill missing translations."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "La validación del sitio falló",
"siteValidation.error.apply": "La aplicación de la validación falló",
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
"menu.item.validateTranslations": "Validar traducciones",
"translationValidation.tabTitle": "Validación de traducciones",
"translationValidation.title": "Validar traducciones",
"translationValidation.summary": "Filas de BD revisadas: {dbRows} · Archivos revisados: {files} · Filas de BD inválidas: {invalidDb} · Archivos inválidos: {invalidFiles}",
"translationValidation.loading": "Validando traducciones...",
"translationValidation.empty": "Ejecuta Blog -> Validar traducciones para inspeccionar la integridad de las traducciones.",
"translationValidation.databaseTitle": "Filas de traducción inválidas en la base de datos",
"translationValidation.filesystemTitle": "Archivos de traducción inválidos en disco",
"translationValidation.noneDatabase": "No se encontraron filas de traducción inválidas.",
"translationValidation.noneFilesystem": "No se encontraron archivos de traducción inválidos.",
"translationValidation.error.validate": "La validación de traducciones falló",
"translationValidation.issue.sameLanguage": "El idioma de la traducción coincide con el idioma canónico de la entrada",
"translationValidation.issue.missingSource": "La traducción apunta a una entrada de origen inexistente",
"translationValidation.issue.doNotTranslate": "La entrada está marcada como no-traducir pero tiene traducciones",
"translationValidation.issue.contentInDatabase": "Traducción publicada con contenido en la BD en lugar del sistema de archivos",
"translationValidation.field.translationFor": "Entrada de origen",
"translationValidation.field.translationId": "Fila de traducción",
"translationValidation.field.title": "Título",
"translationValidation.field.languages": "Idiomas",
"translationValidation.field.filePath": "Archivo",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalidar",
"translationValidation.revalidating": "Revalidando…",
"translationValidation.fix": "Corregir problemas",
"translationValidation.fixing": "Corrigiendo…",
"translationValidation.toast.fixSuccess": "{dbRows} filas de BD y {files} archivos eliminados, {flushed} traducciones escritas a disco",
"translationValidation.error.fix": "Error al corregir traducciones inválidas",
"menuEditor.tabTitle": "Menú del blog",
"menuEditor.title": "Editor del menú del blog",
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
@@ -412,7 +439,7 @@
"metadataDiff.fieldFilter.toggle": "Filtrar por {field}",
"metadataDiff.sync.failed": "falló",
"metadataDiff.sync.dbToFile.title": "Actualizar archivos con valores de la base de datos",
"metadataDiff.sync.dbToFile.short": "BD\u2192A",
"metadataDiff.sync.dbToFile.short": "BDA",
"metadataDiff.sync.dbToFile.success": "Se sincronizaron {success} entradas a archivos{falló}",
"metadataDiff.sync.dbToFile.error": "No se pudo sincronizar a archivos",
"metadataDiff.sync.fileToDb.title": "Actualizar base de datos con valores de archivos",
@@ -433,7 +460,7 @@
"metadataDiff.orphanFiles.badge": "Archivo huérfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Ruta",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importButton": "D BD",
"metadataDiff.orphanFiles.importTitle": "Importar todos los archivos huérfanos a la base de datos",
"metadataDiff.orphanFiles.importing": "Importando…",
"metadataDiff.orphanFiles.importSuccess": "{success} archivos huérfanos importados{failed}",
@@ -461,6 +488,7 @@
"sidebar.published": "Publicadas",
"sidebar.archived": "Archivadas",
"sidebar.untitled": "Sin título",
"sidebar.languagesAvailable": "{count} idiomas disponibles",
"sidebar.noMatchingPosts": "No hay entradas coincidentes",
"sidebar.createFirstPost": "Crea tu primera entrada",
"sidebar.loadMore": "Cargar más ({loaded} de {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Idioma principal",
"settings.project.mainLanguageDescription": "Idioma principal del contenido del blog. Los títulos, textos alternativos y pies generados por IA usarán este idioma.",
"settings.project.blogLanguagesLabel": "Idiomas del blog",
"settings.project.blogLanguagesDescription": "Idiomas en los que se genera el blog. El idioma principal siempre está incluido. Los idiomas adicionales generan subárboles traducidos.",
"settings.project.defaultAuthorLabel": "Autor predeterminado",
"settings.project.defaultAuthorDescription": "Nombre de autor predeterminado para nuevas entradas y medios. Se puede reemplazar por elemento.",
"settings.project.defaultAuthorPlaceholder": "Nombre del autor",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Vista previa de la entrada",
"editor.previewLoading": "Cargando vista previa...",
"editor.metadata.toggle": "Metadatos",
"editor.translations.title": "Traducciones",
"editor.translations.currentLanguage": "Idioma actual: {language}",
"editor.translations.none": "Todavía no hay traducciones.",
"editor.translations.selectTarget": "Selecciona el idioma de destino",
"editor.translations.translateButton": "Traducir a...",
"editor.translations.translateTitle": "Crear o actualizar una traducción con IA",
"editor.translations.translating": "Traduciendo...",
"editor.translations.refresh": "Actualizar",
"editor.translations.refreshTitle": "Regenerar esta traducción con IA",
"editor.translations.publish": "Publicar",
"editor.translations.publishTitle": "Publicar esta traducción en su archivo Markdown",
"editor.translations.publishing": "Publicando...",
"editor.translations.missing": "Faltan: {languages}",
"editor.translations.complete": "Todos los idiomas de traducción compatibles están disponibles.",
"editor.translations.translateSuccess": "Traducción actualizada para {language}",
"editor.translations.translateFailed": "La traducción falló",
"editor.translations.publishSuccess": "Traducción publicada para {language}",
"editor.translations.publishFailed": "No se pudo publicar la traducción",
"editor.translations.status.draft": "Borrador",
"editor.translations.status.published": "Publicada",
"editor.translations.status.archived": "Archivada",
"editor.excerpt.toggle": "Extracto",
"editor.footer.created": "Creado",
"editor.footer.updated": "Actualizado",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
"editor.media.quickActions.detectLanguageTitle": "Detectar idioma",
"editor.media.quickActions.detectLanguageDescription": "Detectar el idioma de los metadatos con IA",
"editor.media.quickActions.translateTitle": "Traducir a…",
"editor.media.quickActions.translateDescription": "Crear o actualizar una traducción con IA",
"editor.media.translations.currentLanguage": "Idioma actual: {language}",
"editor.media.translations.selectTarget": "Seleccionar idioma de destino",
"editor.post.quickActions.title": "Acciones rápidas",
"editor.post.quickActions.analyzing": "⏳ Analizando…",
"editor.post.quickActions.button": "⚡ Acciones rápidas",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Pie de foto",
"editor.media.field.tags": "Etiquetas",
"editor.media.field.author": "Autor",
"editor.media.field.language": "Idioma",
"editor.media.field.languageNone": "No definido",
"editor.media.translations.title": "Traducciones",
"editor.media.translations.none": "Aún no hay traducciones.",
"editor.media.translations.translateButton": "Traducir a…",
"editor.media.translations.translating": "Traduciendo…",
"editor.media.translations.translateSuccess": "Traducción actualizada para {language}",
"editor.media.translations.translateFailed": "Error en la traducción",
"editor.media.translations.refresh": "Actualizar",
"editor.media.translations.refreshTitle": "Regenerar esta traducción con IA",
"editor.media.translations.deleteTitle": "Eliminar esta traducción",
"editor.media.translations.deleted": "Traducción eliminada para {language}",
"editor.media.translations.deleteFailed": "Error al eliminar la traducción",
"editor.media.translations.editTitle": "Editar traducción — {language}",
"editor.media.translations.saved": "Traducción guardada para {language}",
"editor.media.translations.saveFailed": "Error al guardar la traducción",
"editor.media.toast.languageDetected": "Idioma detectado: {language}",
"editor.media.error.detectLanguage": "Error al detectar el idioma",
"editor.media.placeholder.title": "Introduce un título",
"editor.media.placeholder.altText": "Describe la imagen para accesibilidad",
"editor.media.placeholder.caption": "Añadir pie de foto",
@@ -1097,9 +1172,7 @@
"importAnalysis.usedIn": "Usado en: {items}{more}",
"importAnalysis.moreSuffix": ", +{count} más",
"importAnalysis.noParameters": "(sin parámetros)",
"sidebar.nav.mcp": "Servidor MCP",
"settings.mcp.title": "Servidor MCP",
"settings.mcp.description": "Configure el servidor Model Context Protocol que permite a los agentes de programación IA interactuar con su blog.",
"settings.mcp.statusLabel": "Estado del servidor",
@@ -1132,5 +1205,9 @@
"duplicatesView.checkAll": "Seleccionar todo",
"duplicatesView.uncheckAll": "Deseleccionar todo",
"duplicatesView.dismissChecked": "Descartar seleccionados ({count})",
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología."
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología.",
"editor.doNotTranslateLabel": "No traducir",
"blog.fillMissing.nothingToDo": "Todas las traducciones están al día.",
"blog.fillMissing.started": "Tarea de traducción iniciada. Consulte el panel de tareas para ver el progreso.",
"blog.fillMissing.error": "Error al rellenar las traducciones faltantes."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Échec de la validation du site",
"siteValidation.error.apply": "Échec de lapplication de la validation",
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
"menu.item.validateTranslations": "Valider les traductions",
"translationValidation.tabTitle": "Validation des traductions",
"translationValidation.title": "Valider les traductions",
"translationValidation.summary": "Lignes BD vérifiées : {dbRows} · Fichiers vérifiés : {files} · Lignes BD invalides : {invalidDb} · Fichiers invalides : {invalidFiles}",
"translationValidation.loading": "Validation des traductions en cours...",
"translationValidation.empty": "Exécutez Blog -> Valider les traductions pour inspecter lintégrité des traductions.",
"translationValidation.databaseTitle": "Lignes de traduction invalides dans la base de données",
"translationValidation.filesystemTitle": "Fichiers de traduction invalides sur le disque",
"translationValidation.noneDatabase": "Aucune ligne de traduction invalide trouvée.",
"translationValidation.noneFilesystem": "Aucun fichier de traduction invalide trouvé.",
"translationValidation.error.validate": "Échec de la validation des traductions",
"translationValidation.issue.sameLanguage": "La langue de traduction correspond à la langue canonique de larticle",
"translationValidation.issue.missingSource": "La traduction pointe vers un article source manquant",
"translationValidation.issue.doNotTranslate": "L'article est marqué ne-pas-traduire mais a des traductions",
"translationValidation.issue.contentInDatabase": "Traduction publiée avec contenu encore en base au lieu du système de fichiers",
"translationValidation.field.translationFor": "Article source",
"translationValidation.field.translationId": "Ligne de traduction",
"translationValidation.field.title": "Titre",
"translationValidation.field.languages": "Langues",
"translationValidation.field.filePath": "Fichier",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Revalider",
"translationValidation.revalidating": "Revalidation…",
"translationValidation.fix": "Corriger les problèmes",
"translationValidation.fixing": "Correction…",
"translationValidation.toast.fixSuccess": "{dbRows} lignes DB et {files} fichiers supprimés, {flushed} traductions écrites sur disque",
"translationValidation.error.fix": "Échec de la correction des traductions invalides",
"menuEditor.tabTitle": "Menu du blog",
"menuEditor.title": "Éditeur du menu du blog",
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
@@ -419,7 +446,7 @@
"metadataDiff.orphanFiles.badge": "Fichier orphelin",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Chemin",
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
"metadataDiff.orphanFiles.importButton": "D BD",
"metadataDiff.orphanFiles.importTitle": "Importer tous les fichiers orphelins dans la base de données",
"metadataDiff.orphanFiles.importing": "Importation…",
"metadataDiff.orphanFiles.importSuccess": "{success} fichiers orphelins importés{failed}",
@@ -461,6 +488,7 @@
"sidebar.published": "Publiés",
"sidebar.archived": "Archivés",
"sidebar.untitled": "Sans titre",
"sidebar.languagesAvailable": "{count} langues disponibles",
"sidebar.noMatchingPosts": "Aucun article correspondant",
"sidebar.createFirstPost": "Créer votre premier article",
"sidebar.loadMore": "Charger plus ({loaded} sur {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Langue principale",
"settings.project.mainLanguageDescription": "Langue principale de votre contenu. Les titres, textes alternatifs et légendes générés par lIA utiliseront cette langue.",
"settings.project.blogLanguagesLabel": "Langues du blog",
"settings.project.blogLanguagesDescription": "Langues dans lesquelles le blog est rendu. La langue principale est toujours incluse. Les langues supplémentaires génèrent des sous-arborescences traduites.",
"settings.project.defaultAuthorLabel": "Auteur par défaut",
"settings.project.defaultAuthorDescription": "Nom dauteur par défaut pour les nouveaux articles et médias. Peut être remplacé par élément.",
"settings.project.defaultAuthorPlaceholder": "Nom de lauteur",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Aperçu de larticle",
"editor.previewLoading": "Chargement de l'aperçu...",
"editor.metadata.toggle": "Métadonnées",
"editor.translations.title": "Traductions",
"editor.translations.currentLanguage": "Langue actuelle : {language}",
"editor.translations.none": "Aucune traduction pour le moment.",
"editor.translations.selectTarget": "Sélectionner la langue cible",
"editor.translations.translateButton": "Traduire vers...",
"editor.translations.translateTitle": "Créer ou actualiser une traduction avec lIA",
"editor.translations.translating": "Traduction...",
"editor.translations.refresh": "Actualiser",
"editor.translations.refreshTitle": "Regénérer cette traduction avec lIA",
"editor.translations.publish": "Publier",
"editor.translations.publishTitle": "Publier cette traduction dans son fichier Markdown",
"editor.translations.publishing": "Publication...",
"editor.translations.missing": "Manquantes : {languages}",
"editor.translations.complete": "Toutes les langues de traduction prises en charge sont disponibles.",
"editor.translations.translateSuccess": "Traduction mise à jour pour {language}",
"editor.translations.translateFailed": "La traduction a échoué",
"editor.translations.publishSuccess": "Traduction publiée pour {language}",
"editor.translations.publishFailed": "Échec de la publication de la traduction",
"editor.translations.status.draft": "Brouillon",
"editor.translations.status.published": "Publié",
"editor.translations.status.archived": "Archivé",
"editor.excerpt.toggle": "Extrait",
"editor.footer.created": "Créé",
"editor.footer.updated": "Mis à jour",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
"editor.media.quickActions.detectLanguageTitle": "Détecter la langue",
"editor.media.quickActions.detectLanguageDescription": "Détecter la langue des métadonnées avec lIA",
"editor.media.quickActions.translateTitle": "Traduire en…",
"editor.media.quickActions.translateDescription": "Créer ou actualiser une traduction avec lIA",
"editor.media.translations.currentLanguage": "Langue actuelle : {language}",
"editor.media.translations.selectTarget": "Sélectionner la langue cible",
"editor.post.quickActions.title": "Actions rapides",
"editor.post.quickActions.analyzing": "⏳ Analyse…",
"editor.post.quickActions.button": "⚡ Actions rapides",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Légende",
"editor.media.field.tags": "Tags",
"editor.media.field.author": "Auteur",
"editor.media.field.language": "Langue",
"editor.media.field.languageNone": "Non défini",
"editor.media.translations.title": "Traductions",
"editor.media.translations.none": "Aucune traduction pour le moment.",
"editor.media.translations.translateButton": "Traduire en…",
"editor.media.translations.translating": "Traduction…",
"editor.media.translations.translateSuccess": "Traduction mise à jour pour {language}",
"editor.media.translations.translateFailed": "Échec de la traduction",
"editor.media.translations.refresh": "Actualiser",
"editor.media.translations.refreshTitle": "Régénérer cette traduction par IA",
"editor.media.translations.deleteTitle": "Supprimer cette traduction",
"editor.media.translations.deleted": "Traduction supprimée pour {language}",
"editor.media.translations.deleteFailed": "Échec de la suppression de la traduction",
"editor.media.translations.editTitle": "Modifier la traduction — {language}",
"editor.media.translations.saved": "Traduction enregistrée pour {language}",
"editor.media.translations.saveFailed": "Échec de lenregistrement de la traduction",
"editor.media.toast.languageDetected": "Langue détectée : {language}",
"editor.media.error.detectLanguage": "Échec de la détection de la langue",
"editor.media.placeholder.title": "Saisissez un titre",
"editor.media.placeholder.altText": "Décrivez limage pour laccessibilité",
"editor.media.placeholder.caption": "Ajouter une légende",
@@ -1130,5 +1205,9 @@
"duplicatesView.checkAll": "Tout cocher",
"duplicatesView.uncheckAll": "Tout décocher",
"duplicatesView.dismissChecked": "Ignorer cochés ({count})",
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie."
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie.",
"editor.doNotTranslateLabel": "Ne pas traduire",
"blog.fillMissing.nothingToDo": "Toutes les traductions sont à jour.",
"blog.fillMissing.started": "Tâche de traduction démarrée. Consultez le panneau des tâches pour le progrès.",
"blog.fillMissing.error": "Échec du remplissage des traductions manquantes."
}

View File

@@ -54,6 +54,33 @@
"siteValidation.error.validate": "Validazione del sito non riuscita",
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
"menu.item.validateTranslations": "Valida traduzioni",
"translationValidation.tabTitle": "Validazione traduzioni",
"translationValidation.title": "Valida traduzioni",
"translationValidation.summary": "Righe DB controllate: {dbRows} · File controllati: {files} · Righe DB non valide: {invalidDb} · File non validi: {invalidFiles}",
"translationValidation.loading": "Validazione traduzioni in corso...",
"translationValidation.empty": "Esegui Blog -> Valida traduzioni per controllare lintegrità delle traduzioni.",
"translationValidation.databaseTitle": "Righe di traduzione non valide nel database",
"translationValidation.filesystemTitle": "File di traduzione non validi sul disco",
"translationValidation.noneDatabase": "Nessuna riga di traduzione non valida trovata.",
"translationValidation.noneFilesystem": "Nessun file di traduzione non valido trovato.",
"translationValidation.error.validate": "Validazione traduzioni non riuscita",
"translationValidation.issue.sameLanguage": "La lingua della traduzione coincide con la lingua canonica del post",
"translationValidation.issue.missingSource": "La traduzione punta a un post sorgente mancante",
"translationValidation.issue.doNotTranslate": "Il post è contrassegnato come non-tradurre ma ha traduzioni",
"translationValidation.issue.contentInDatabase": "Traduzione pubblicata con contenuto nel DB invece del filesystem",
"translationValidation.field.translationFor": "Post sorgente",
"translationValidation.field.translationId": "Riga traduzione",
"translationValidation.field.title": "Titolo",
"translationValidation.field.languages": "Lingue",
"translationValidation.field.filePath": "File",
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
"translationValidation.revalidate": "Rivalidare",
"translationValidation.revalidating": "Rivalidazione…",
"translationValidation.fix": "Correggi problemi",
"translationValidation.fixing": "Correzione…",
"translationValidation.toast.fixSuccess": "{dbRows} righe DB e {files} file eliminati, {flushed} traduzioni scritte su disco",
"translationValidation.error.fix": "Correzione delle traduzioni non valide fallita",
"menuEditor.tabTitle": "Menu blog",
"menuEditor.title": "Editor del menu blog",
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
@@ -419,18 +446,18 @@
"metadataDiff.orphanFiles.badge": "File orfano",
"metadataDiff.orphanFiles.slug": "Slug",
"metadataDiff.orphanFiles.path": "Percorso",
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
"metadataDiff.orphanFiles.importButton": "D DB",
"metadataDiff.orphanFiles.importTitle": "Importa tutti i file orfani nel database",
"metadataDiff.orphanFiles.importing": "Importazione…",
"metadataDiff.orphanFiles.importSuccess": "{success} file orfani importati{failed}",
"metadataDiff.orphanFiles.importError": "Impossibile importare i file orfani",
"metadataDiff.sync.failed": "fallito",
"metadataDiff.sync.dbToFile.title": "Aggiorna i file con i valori del database",
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
"metadataDiff.sync.dbToFile.short": "DBF",
"metadataDiff.sync.dbToFile.success": "Sincronizzati {success} post nei file{fallito}",
"metadataDiff.sync.dbToFile.error": "Impossibile sincronizzare nei file",
"metadataDiff.sync.fileToDb.title": "Aggiorna il database con i valori dei file",
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
"metadataDiff.sync.fileToDb.short": "FDB",
"metadataDiff.sync.fileToDb.success": "Sincronizzati {success} file nel database{fallito}",
"metadataDiff.sync.fileToDb.error": "Impossibile sincronizzare nel database",
"metadataDiff.value.database": "Database locale",
@@ -461,6 +488,7 @@
"sidebar.published": "Pubblicati",
"sidebar.archived": "Archiviati",
"sidebar.untitled": "Senza titolo",
"sidebar.languagesAvailable": "{count} lingue disponibili",
"sidebar.noMatchingPosts": "Nessun post corrispondente",
"sidebar.createFirstPost": "Crea il tuo primo post",
"sidebar.loadMore": "Carica altro ({loaded} di {total})",
@@ -525,6 +553,8 @@
"settings.project.publicUrlPlaceholder": "https://example.com",
"settings.project.mainLanguageLabel": "Lingua principale",
"settings.project.mainLanguageDescription": "Lingua principale dei contenuti del blog. Titoli, alt text e didascalie generate dallIA useranno questa lingua.",
"settings.project.blogLanguagesLabel": "Lingue del blog",
"settings.project.blogLanguagesDescription": "Lingue in cui viene generato il blog. La lingua principale è sempre inclusa. Le lingue aggiuntive generano sottocartelle tradotte.",
"settings.project.defaultAuthorLabel": "Autore predefinito",
"settings.project.defaultAuthorDescription": "Nome autore predefinito per nuovi post e media. Può essere modificato per singolo elemento.",
"settings.project.defaultAuthorPlaceholder": "Nome autore",
@@ -578,6 +608,27 @@
"editor.previewFrameTitle": "Anteprima post",
"editor.previewLoading": "Caricamento anteprima...",
"editor.metadata.toggle": "Metadati",
"editor.translations.title": "Traduzioni",
"editor.translations.currentLanguage": "Lingua corrente: {language}",
"editor.translations.none": "Nessuna traduzione disponibile.",
"editor.translations.selectTarget": "Seleziona lingua di destinazione",
"editor.translations.translateButton": "Traduci in...",
"editor.translations.translateTitle": "Crea o aggiorna una traduzione con l'IA",
"editor.translations.translating": "Traduzione in corso...",
"editor.translations.refresh": "Aggiorna",
"editor.translations.refreshTitle": "Rigenera questa traduzione con l'IA",
"editor.translations.publish": "Pubblica",
"editor.translations.publishTitle": "Pubblica questa traduzione nel suo file Markdown",
"editor.translations.publishing": "Pubblicazione...",
"editor.translations.missing": "Mancanti: {languages}",
"editor.translations.complete": "Tutte le lingue di traduzione supportate sono disponibili.",
"editor.translations.translateSuccess": "Traduzione aggiornata per {language}",
"editor.translations.translateFailed": "Traduzione non riuscita",
"editor.translations.publishSuccess": "Traduzione pubblicata per {language}",
"editor.translations.publishFailed": "Pubblicazione della traduzione non riuscita",
"editor.translations.status.draft": "Bozza",
"editor.translations.status.published": "Pubblicato",
"editor.translations.status.archived": "Archiviato",
"editor.excerpt.toggle": "Estratto",
"editor.footer.created": "Creato",
"editor.footer.updated": "Aggiornato",
@@ -927,6 +978,12 @@
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
"editor.media.quickActions.detectLanguageTitle": "Rileva lingua",
"editor.media.quickActions.detectLanguageDescription": "Rileva la lingua dai metadati con lIA",
"editor.media.quickActions.translateTitle": "Traduci in…",
"editor.media.quickActions.translateDescription": "Crea o aggiorna una traduzione con lIA",
"editor.media.translations.currentLanguage": "Lingua corrente: {language}",
"editor.media.translations.selectTarget": "Seleziona la lingua di destinazione",
"editor.post.quickActions.title": "Azioni rapide",
"editor.post.quickActions.analyzing": "⏳ Analisi…",
"editor.post.quickActions.button": "⚡ Azioni rapide",
@@ -949,6 +1006,24 @@
"editor.media.field.caption": "Didascalia",
"editor.media.field.tags": "Tag",
"editor.media.field.author": "Autore",
"editor.media.field.language": "Lingua",
"editor.media.field.languageNone": "Non impostata",
"editor.media.translations.title": "Traduzioni",
"editor.media.translations.none": "Nessuna traduzione ancora.",
"editor.media.translations.translateButton": "Traduci in…",
"editor.media.translations.translating": "Traduzione…",
"editor.media.translations.translateSuccess": "Traduzione aggiornata per {language}",
"editor.media.translations.translateFailed": "Traduzione fallita",
"editor.media.translations.refresh": "Aggiorna",
"editor.media.translations.refreshTitle": "Rigenera questa traduzione tramite IA",
"editor.media.translations.deleteTitle": "Elimina questa traduzione",
"editor.media.translations.deleted": "Traduzione eliminata per {language}",
"editor.media.translations.deleteFailed": "Eliminazione della traduzione fallita",
"editor.media.translations.editTitle": "Modifica traduzione — {language}",
"editor.media.translations.saved": "Traduzione salvata per {language}",
"editor.media.translations.saveFailed": "Salvataggio della traduzione fallito",
"editor.media.toast.languageDetected": "Lingua rilevata: {language}",
"editor.media.error.detectLanguage": "Rilevamento della lingua fallito",
"editor.media.placeholder.title": "Inserisci un titolo",
"editor.media.placeholder.altText": "Descrivi limmagine per laccessibilità",
"editor.media.placeholder.caption": "Aggiungi una didascalia",
@@ -1130,5 +1205,9 @@
"duplicatesView.checkAll": "Seleziona tutto",
"duplicatesView.uncheckAll": "Deseleziona tutto",
"duplicatesView.dismissChecked": "Ignora selezionati ({count})",
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia."
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia.",
"editor.doNotTranslateLabel": "Non tradurre",
"blog.fillMissing.nothingToDo": "Tutte le traduzioni sono aggiornate.",
"blog.fillMissing.started": "Attività di traduzione avviata. Controlla il pannello attività per il progresso.",
"blog.fillMissing.error": "Impossibile completare le traduzioni mancanti."
}

View File

@@ -16,6 +16,7 @@ export type EditorRoute =
| 'documentation'
| 'api-documentation'
| 'site-validation'
| 'translation-validation'
| 'scripts'
| 'templates'
| 'find-duplicates';
@@ -34,6 +35,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
documentation: 'documentation',
'api-documentation': 'api-documentation',
'site-validation': 'site-validation',
'translation-validation': 'translation-validation',
scripts: 'scripts',
templates: 'templates',
'find-duplicates': 'find-duplicates',

View File

@@ -8,6 +8,7 @@ export interface CreateAndFocusPostOptions {
setSelectedPost: (postId: string) => void;
ensurePostsSidebar?: () => void;
onError?: (error: unknown) => void;
categories?: string[];
}
export async function createAndFocusPost(options: CreateAndFocusPostOptions): Promise<string | null> {
@@ -16,7 +17,7 @@ export async function createAndFocusPost(options: CreateAndFocusPostOptions): Pr
title: '',
content: '',
tags: [],
categories: [],
categories: options.categories ?? [],
});
if (!post) {

View File

@@ -10,6 +10,7 @@ export type SingletonToolTabKey =
| 'api-documentation'
| 'metadata-diff'
| 'site-validation'
| 'translation-validation'
| 'find-duplicates';
export interface CanonicalTabSpec {
@@ -34,6 +35,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false },
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
'translation-validation': { type: 'translation-validation', id: 'translation-validation', isTransient: false },
'find-duplicates': { type: 'find-duplicates', id: 'find-duplicates', isTransient: false },
};

View File

@@ -0,0 +1,24 @@
import type { TranslationValidationReport } from '../../main/shared/electronApi';
const TRANSLATION_VALIDATION_REPORT_PREFIX = 'bds-translation-validation-report';
function buildStorageKey(projectId: string): string {
return `${TRANSLATION_VALIDATION_REPORT_PREFIX}:${projectId}`;
}
export function persistTranslationValidationReport(projectId: string, report: TranslationValidationReport): void {
localStorage.setItem(buildStorageKey(projectId), JSON.stringify(report));
}
export function getPersistedTranslationValidationReport(projectId: string): TranslationValidationReport | null {
const raw = localStorage.getItem(buildStorageKey(projectId));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as TranslationValidationReport;
} catch {
return null;
}
}

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates' | 'find-duplicates';
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'translation-validation' | 'scripts' | 'templates' | 'find-duplicates';
export interface Tab {
type: TabType;

View File

@@ -4,7 +4,8 @@ export const BDS_EVENT_TEMPLATES_CHANGED = 'bds:templates-changed' as const;
export type BdsWindowEventName =
| typeof BDS_EVENT_SCRIPTS_CHANGED
| typeof BDS_EVENT_TEMPLATES_CHANGED
| 'bds:site-validation-updated';
| 'bds:site-validation-updated'
| 'bds:translation-validation-updated';
export function addWindowEventListener<TDetail = unknown>(
eventName: BdsWindowEventName,

View File

@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, rm, readdir, stat, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import type { PostData } from '../../src/main/engine/PostEngine';
import type { PostData, PostTranslationData } from '../../src/main/engine/PostEngine';
import { resolveUiLanguageFromSystemLocale } from '../../src/main/shared/i18n';
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
import { createPreviewBackedGenerationRouteRenderer } from '../../src/main/engine/GenerationRouteRendererFactory';
const generatedFileHashes = new Map<string, string>();
const generatedFileUpdatedAt = new Map<string, number>();
@@ -72,6 +73,8 @@ vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
getPostsFiltered: vi.fn(async () => []),
getPublishedVersion: vi.fn(async () => null),
getPost: vi.fn(async () => null),
getPostTranslation: vi.fn(async () => null),
getPostTranslations: vi.fn(async () => []),
setProjectContext: vi.fn(),
};
return {
@@ -115,11 +118,13 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? '# Test\n\nBody text',
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt ?? createdAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
availableLanguages: overrides.availableLanguages ?? (overrides.language ? [overrides.language] : []),
};
}
@@ -197,6 +202,8 @@ describe('BlogGenerationEngine', () => {
mockPostEngine.getPost.mockImplementation(async (id: string) => {
return posts.find((p) => p.id === id) ?? null;
});
mockPostEngine.getPostTranslation.mockResolvedValue(null);
mockPostEngine.getPostTranslations.mockResolvedValue([]);
}
async function generate(
@@ -682,11 +689,122 @@ describe('BlogGenerationEngine', () => {
const monthArchivePath = path.join(tempDir, 'html', '2020', '02', 'index.html');
const monthHtml = await readFile(monthArchivePath, 'utf-8');
expect(monthHtml).toContain('<html lang="fr">');
expect(monthHtml).toContain('<html lang="fr"');
expect(monthHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
expect(monthHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
});
it('renders canonical single-post route with project main language content when available', async () => {
const canonicalPost = makePost({
id: 'post-1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nCanonical body',
language: 'en',
createdAt: new Date('2025-01-15T10:00:00Z'),
});
setupPosts([canonicalPost]);
mockPostEngine.getPostTranslation.mockImplementation(async (postId: string, language: string) => {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1-fr',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
};
}
return null;
});
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'fr',
}, vi.fn());
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('post-1', 'fr');
expect(canonicalHtml).toContain('<html lang="fr"');
expect(canonicalHtml).toContain('Bonjour le monde');
expect(canonicalHtml).toContain('Corps FR');
expect(canonicalHtml).not.toContain('Canonical body');
});
it('preview-backed generation route renderer prefers project main language content on canonical single-post routes', async () => {
const canonicalPost = makePost({
id: 'post-1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nCanonical body',
language: 'en',
createdAt: new Date('2025-01-15T10:00:00Z'),
});
const renderRoute = createPreviewBackedGenerationRouteRenderer({
options: {
projectId: 'test',
dataDir: tempDir,
projectName: 'Test Blog',
language: 'fr',
},
maxPostsPerPage: 50,
publishedPostsForLookup: [canonicalPost],
engines: {
postEngine: {
getPostsFiltered: mockPostEngine.getPostsFiltered,
getPublishedVersion: mockPostEngine.getPublishedVersion,
getPost: mockPostEngine.getPost,
getPostTranslation: vi.fn(async (postId: string, language: string) => {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1-fr',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
} satisfies PostTranslationData;
}
return null;
}),
hasPublishedVersion: mockPostEngine.hasPublishedVersion,
setProjectContext: mockPostEngine.setProjectContext,
},
mediaEngine: mockMediaEngine,
postMediaEngine: mockPostMediaEngine,
},
});
const html = await renderRoute('/2025/01/15/hello-world');
expect(html).not.toBeNull();
expect(html).toContain('<html lang="fr"');
expect(html).toContain('Bonjour le monde');
expect(html).toContain('Corps FR');
expect(html).not.toContain('Canonical body');
});
it('excludes draft-only posts from generated pages', async () => {
const posts = [
makePost({ id: '1', slug: 'published', title: 'Published', status: 'published' }),
@@ -1221,6 +1339,121 @@ describe('BlogGenerationEngine', () => {
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
});
it('generates published translation pages with alternate links and sitemap entries', async () => {
const sourcePost = makePost({
id: '1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nEnglish body',
language: 'en',
availableLanguages: ['en', 'fr'],
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
});
const translationsByPostId = new Map<string, PostTranslationData[]>([
['1', [{
id: 'translation-1-fr',
projectId: 'default',
translationFor: '1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
}]],
]);
setupPosts([sourcePost]);
mockPostEngine.getPostTranslations.mockImplementation(async (postId: string) => translationsByPostId.get(postId) ?? []);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn());
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
const translationHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'), 'utf-8');
const sitemap = await readFile(path.join(tempDir, 'html', 'sitemap.xml'), 'utf-8');
expect(canonicalHtml).toContain('hreflang="fr"');
expect(canonicalHtml).toContain('href="/2025/01/15/hello-world.fr"');
expect(translationHtml).toContain('<html lang="fr"');
expect(translationHtml).toContain('Bonjour le monde');
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world/</loc>');
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world.fr/</loc>');
});
it('preserves post engine method binding when loading published translations', async () => {
const sourcePost = makePost({
id: '1',
slug: 'hello-world',
title: 'Hello World',
content: '# Hello World\n\nEnglish body',
language: 'en',
availableLanguages: ['en', 'fr'],
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
});
const translationsByPostId = new Map<string, PostTranslationData[]>([
['1', [{
id: 'translation-1-fr',
projectId: 'default',
translationFor: '1',
language: 'fr',
title: 'Bonjour le monde',
excerpt: 'Resume FR',
content: '# Bonjour le monde\n\nCorps FR',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
}]],
]);
const postEngine = {
translationsByPostId,
setProjectContext: vi.fn(),
async getPostsFiltered(filter: { status?: string }) {
return filter.status === 'published' ? [sourcePost] : [];
},
async getPublishedVersion() {
return null;
},
async getPost(postId: string) {
return postId === sourcePost.id ? sourcePost : null;
},
async getPostTranslations(postId: string) {
return this.translationsByPostId.get(postId) ?? [];
},
};
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(postEngine as any, mockMediaEngine, mockPostMediaEngine);
await expect(engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn())).resolves.toMatchObject({
postCount: 1,
});
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'))).toBe(true);
});
it('applies validation by generating only missing category and tag routes', async () => {
const posts = [
makePost({ id: '1', slug: 'ordered-post', categories: ['news'], tags: ['ordered-tag'], createdAt: new Date('2025-01-15T10:00:00Z') }),
@@ -1617,6 +1850,49 @@ describe('BlogGenerationEngine', () => {
expect(await fileExists(path.join(tempDir, 'html', 'hello-world', 'index.html'))).toBe(false);
});
it('generates translated static page routes for published page translations', async () => {
const pagePost = makePost({
id: 'page-1',
slug: 'tag-cloud',
title: 'Tag Cloud',
categories: ['page'],
language: 'en',
availableLanguages: ['en', 'de'],
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
});
setupPosts([pagePost]);
mockPostEngine.getPostTranslations.mockResolvedValue([{
id: 'translation-page-1-de',
projectId: 'default',
translationFor: 'page-1',
language: 'de',
title: 'Schlagwortwolke',
excerpt: 'Zusammenfassung DE',
content: '# Schlagwortwolke\n\nInhalt DE',
status: 'published',
createdAt: new Date('2025-01-15T10:05:00Z'),
updatedAt: new Date('2025-01-15T10:05:00Z'),
publishedAt: new Date('2025-01-15T10:06:00Z'),
filePath: path.join(tempDir, 'posts', 'tag-cloud.de.md'),
}]);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn());
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud.de', 'index.html'))).toBe(true);
});
it('generates canonical post routes only and does not generate aliases', async () => {
const posts = [
makePost({ id: '1', slug: 'alias-test', createdAt: new Date('2025-03-15T10:00:00Z') }),
@@ -1705,6 +1981,167 @@ describe('BlogGenerationEngine', () => {
expect(await fileExists(path.join(tempDir, 'html', 'media'))).toBe(false);
});
it('validateSite reports missing language subtree pages and does not flag them as extra', async () => {
const posts = [
makePost({
id: '1',
slug: 'lang-post',
title: 'Language Post',
categories: ['news'],
tags: ['lang-tag'],
createdAt: new Date('2025-01-15T10:00:00Z'),
}),
];
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
// Generate only main language pages
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
}, vi.fn());
// Validate with blogLanguages including fr - should report missing fr pages
const report = await engine.validateSite({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
expect(report.missingUrlPaths).toContain('/fr');
expect(report.missingUrlPaths).toContain('/fr/2025/01/15/lang-post');
expect(report.missingUrlPaths).toContain('/fr/category/news');
expect(report.missingUrlPaths).toContain('/fr/tag/lang-tag');
expect(report.extraUrlPaths).not.toContain('/fr');
});
it('validateSite reports no missing language pages after full multi-language generation', async () => {
const posts = [
makePost({
id: '1',
slug: 'multilang-post',
title: 'Multi Lang Post',
categories: ['news'],
createdAt: new Date('2025-01-15T10:00:00Z'),
}),
];
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
const report = await engine.validateSite({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
expect(report.missingUrlPaths).toEqual([]);
expect(report.extraUrlPaths).toEqual([]);
});
it('applyValidation renders missing language subtree pages', async () => {
const posts = [
makePost({
id: '1',
slug: 'apply-lang-post',
title: 'Apply Lang Post',
categories: ['news'],
tags: ['apply-lang-tag'],
createdAt: new Date('2025-01-15T10:00:00Z'),
}),
];
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
const result = await engine.applyValidation({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, {
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
sitemapChanged: false,
missingUrlPaths: ['/fr/category/news', '/fr/tag/apply-lang-tag'],
extraUrlPaths: [],
updatedPostUrlPaths: [],
expectedUrlCount: 2,
existingHtmlUrlCount: 0,
}, vi.fn());
expect(result.renderedUrlCount).toBeGreaterThan(0);
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'category', 'news', 'index.html'))).toBe(true);
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'tag', 'apply-lang-tag', 'index.html'))).toBe(true);
});
it('validateSite excludes doNotTranslate posts from language subtree expected urls', async () => {
const translatablePost = makePost({
id: '1',
slug: 'translatable',
title: 'Translatable',
categories: ['news'],
createdAt: new Date('2025-01-15T10:00:00Z'),
});
const dntPost = makePost({
id: '2',
slug: 'no-translate',
title: 'Do Not Translate',
categories: ['news'],
createdAt: new Date('2025-01-16T10:00:00Z'),
doNotTranslate: true,
} as any);
setupPosts([translatablePost, dntPost]);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
const report = await engine.validateSite({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
language: 'en',
blogLanguages: ['en', 'fr'],
}, vi.fn());
expect(report.missingUrlPaths).toEqual([]);
// The dnt post's single page should NOT be expected in /fr/ subtree
expect(report.extraUrlPaths).not.toContain('/fr/2025/01/16/no-translate');
});
it('generates zero pages when there are no published posts', async () => {
const result = await generate([]);
expect(result.pagesGenerated).toBe(0);

View File

@@ -27,6 +27,8 @@ function createMockPostEngine() {
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
getPostTranslation: vi.fn().mockResolvedValue(null),
getPostTranslations: vi.fn().mockResolvedValue([]),
};
}
@@ -1198,6 +1200,81 @@ describe('MCPServer', () => {
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('not found');
});
it('read_post_by_slug with language returns translation content', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
const translation = { id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English content', excerpt: 'English excerpt', status: 'published', createdAt: new Date(), updatedAt: new Date() };
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getPostTranslation.mockResolvedValue(translation);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'read_post_by_slug');
const result = await tool.handler({ slug: 'hallo-welt', language: 'en' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.post.title).toBe('Hello World');
expect(parsed.post.content).toBe('English content');
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
});
it('read_post_by_slug with canonical language returns original post', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'read_post_by_slug');
const result = await tool.handler({ slug: 'hallo-welt', language: 'de' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.post.title).toBe('Hallo Welt');
expect(parsed.post.content).toBe('Deutscher Inhalt');
expect(mockPostEngine.getPostTranslation).not.toHaveBeenCalled();
});
it('read_post_by_slug returns error when translation not found', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutsch', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de'], createdAt: new Date(), updatedAt: new Date() };
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getPostTranslation.mockResolvedValue(null);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'read_post_by_slug');
const result = await tool.handler({ slug: 'hallo-welt', language: 'fr' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('fr');
});
// ── get_post_translations tool ──────────────────────────────────
it('registers get_post_translations tool', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'get_post_translations')).toBe(true);
});
it('get_post_translations returns all translations for a post', async () => {
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', language: 'de', status: 'published', tags: [], categories: [], createdAt: new Date(), updatedAt: new Date() };
const translations = [
{ id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English', status: 'published', createdAt: new Date(), updatedAt: new Date() },
{ id: 't2', translationFor: 'p1', language: 'fr', title: 'Bonjour le Monde', content: 'French', status: 'published', createdAt: new Date(), updatedAt: new Date() },
];
mockPostEngine.getPostBySlug.mockResolvedValue(post);
mockPostEngine.getPostTranslations.mockResolvedValue(translations);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'get_post_translations');
const result = await tool.handler({ slug: 'hallo-welt' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.translations).toHaveLength(2);
expect(parsed.translations[0].language).toBe('en');
expect(parsed.translations[1].language).toBe('fr');
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
});
it('get_post_translations returns error for nonexistent slug', async () => {
mockPostEngine.getPostBySlug.mockResolvedValue(null);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'get_post_translations');
const result = await tool.handler({ slug: 'no-such-slug' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
});
});
// ── Prompt handler behavior ────────────────────────────────────────

View File

@@ -0,0 +1,491 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaEngine } from '../../src/main/engine/MediaEngine';
import { media, mediaTranslations } from '../../src/main/database/schema';
const mockMedia = new Map<string, any>();
const mockTranslations = new Map<string, any>();
const mockFiles = new Map<string, string>();
function resetData(): void {
mockMedia.clear();
mockTranslations.clear();
mockFiles.clear();
}
function getTableRows(table: unknown): any[] {
if (table === media) {
return Array.from(mockMedia.values());
}
if (table === mediaTranslations) {
return Array.from(mockTranslations.values());
}
return [];
}
function extractEqValue(predicate: unknown): string | undefined {
const chunks = (predicate as any)?.queryChunks;
if (!Array.isArray(chunks)) return undefined;
for (const chunk of chunks) {
if (chunk?.value !== undefined && typeof chunk.value === 'string') {
return chunk.value;
}
}
return undefined;
}
function createSelectChain() {
let selectedTable: unknown;
let filterValue: string | undefined;
return {
from: vi.fn().mockImplementation(function from(table: unknown) {
selectedTable = table;
return this;
}),
where: vi.fn().mockImplementation(function where(predicate: unknown) {
filterValue = extractEqValue(predicate);
return this;
}),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(async () => {
const rows = getTableRows(selectedTable);
if (filterValue) {
return rows.filter((row) =>
row.id === filterValue ||
row.translationFor === filterValue ||
row.projectId === filterValue
);
}
return rows;
}),
get: vi.fn().mockImplementation(async () => {
const rows = getTableRows(selectedTable);
if (filterValue) {
return rows.find((row) =>
row.id === filterValue ||
row.translationFor === filterValue ||
row.projectId === filterValue
);
}
return rows[0];
}),
};
}
function createInsertChain(table: unknown) {
return {
values: vi.fn(async (value: any) => {
const rows = Array.isArray(value) ? value : [value];
for (const row of rows) {
if (table === media) {
mockMedia.set(row.id, row);
} else if (table === mediaTranslations) {
mockTranslations.set(row.id, row);
}
}
}),
};
}
function createUpdateChain(table: unknown) {
return {
set: vi.fn().mockImplementation((value: Record<string, unknown>) => ({
where: vi.fn(async (predicate: unknown) => {
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
if (!targetMap || targetMap.size === 0) return;
const targetId = extractEqValue(predicate);
if (targetId && targetMap.has(targetId)) {
const existing = targetMap.get(targetId);
targetMap.set(targetId, { ...existing, ...value });
}
}),
})),
};
}
function createDeleteChain(table: unknown) {
return {
where: vi.fn(async (predicate: unknown) => {
const targetId = extractEqValue(predicate);
if (targetId) {
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
if (targetMap) {
// Try direct key match first
if (targetMap.has(targetId)) {
targetMap.delete(targetId);
} else {
// Filter by translationFor (cascade delete pattern)
for (const [key, row] of targetMap.entries()) {
if ((row as any).translationFor === targetId || (row as any).mediaId === targetId) {
targetMap.delete(key);
}
}
}
}
}
}),
};
}
const mockLocalDb = {
select: vi.fn(() => createSelectChain()),
insert: vi.fn((table: unknown) => createInsertChain(table)),
update: vi.fn((table: unknown) => createUpdateChain(table)),
delete: vi.fn((table: unknown) => createDeleteChain(table)),
};
const mockLocalClient = {
execute: vi.fn(async () => ({ rows: [] })),
};
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => mockLocalClient),
})),
}));
vi.mock('fs/promises', () => ({
access: vi.fn(async (filePath: string) => {
if (!mockFiles.has(filePath)) {
const error = new Error('ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
}),
mkdir: vi.fn(async () => {}),
readFile: vi.fn(async (filePath: string) => {
const content = mockFiles.get(filePath);
if (content == null) {
const error = new Error('ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
}),
readdir: vi.fn(async () => []),
rename: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
writeFile: vi.fn(async (filePath: string, content: string) => {
mockFiles.set(filePath, content);
}),
copyFile: vi.fn(async () => {}),
stat: vi.fn(async () => ({ size: 1024 })),
}));
vi.mock('uuid', () => {
let counter = 1;
return {
v4: vi.fn(() => `uuid-${counter++}`),
};
});
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/electron-test'),
},
}));
function seedMediaItem(overrides: Partial<any> = {}): any {
const id = overrides.id || 'media-1';
const item = {
id,
projectId: 'project-1',
filename: `${id}.jpg`,
originalName: 'photo.jpg',
mimeType: 'image/jpeg',
size: 1024,
width: 800,
height: 600,
title: 'A photo',
alt: 'Alt text',
caption: 'Photo caption',
author: 'Author',
language: null,
filePath: `/tmp/project-1/media/2024/01/${id}.jpg`,
sidecarPath: `/tmp/project-1/media/2024/01/${id}.jpg.meta`,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
checksum: 'abc123',
tags: '[]',
...overrides,
};
mockMedia.set(id, item);
return item;
}
describe('Media translation system', () => {
let engine: MediaEngine;
beforeEach(() => {
vi.clearAllMocks();
resetData();
engine = new MediaEngine();
engine.setProjectContext('project-1', '/tmp/project-1');
});
describe('getMediaTranslation', () => {
it('returns null when no translation exists', async () => {
seedMediaItem();
const result = await engine.getMediaTranslation('media-1', 'fr');
expect(result).toBeNull();
});
it('returns a translation when one exists', async () => {
seedMediaItem();
mockTranslations.set('trans-1', {
id: 'trans-1',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.getMediaTranslation('media-1', 'fr');
expect(result).toMatchObject({
id: 'trans-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
});
});
describe('getMediaTranslations', () => {
it('returns all translations for a media item', async () => {
seedMediaItem();
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
mockTranslations.set('trans-de', {
id: 'trans-de',
projectId: 'project-1',
translationFor: 'media-1',
language: 'de',
title: 'Ein Foto',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.getMediaTranslations('media-1');
expect(result).toHaveLength(2);
expect(result.map(t => t.language).sort()).toEqual(['de', 'fr']);
});
it('returns empty array when no translations exist', async () => {
seedMediaItem();
const result = await engine.getMediaTranslations('media-1');
expect(result).toEqual([]);
});
});
describe('upsertMediaTranslation', () => {
it('creates a new translation', async () => {
seedMediaItem({ language: 'en' });
const result = await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
expect(result).toMatchObject({
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
expect(result.id).toBeTruthy();
});
it('updates an existing translation instead of creating duplicates', async () => {
seedMediaItem({ language: 'en' });
const first = await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Titre v1',
});
const second = await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Titre v2',
alt: 'Alt v2',
});
expect(second.id).toBe(first.id);
const translations = await engine.getMediaTranslations('media-1');
expect(translations).toHaveLength(1);
expect(translations[0].title).toBe('Titre v2');
});
it('rejects translations whose language matches the canonical media language', async () => {
seedMediaItem({ language: 'de' });
await expect(
engine.upsertMediaTranslation('media-1', 'DE', {
title: 'Titel',
})
).rejects.toThrow('Translation language must differ from canonical media language');
});
it('rejects translations for non-existent media', async () => {
await expect(
engine.upsertMediaTranslation('nonexistent', 'fr', { title: 'Test' })
).rejects.toThrow('Media item not found');
});
});
describe('deleteMediaTranslation', () => {
it('deletes an existing translation', async () => {
seedMediaItem();
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.deleteMediaTranslation('media-1', 'fr');
expect(result).toBe(true);
});
it('returns false when translation does not exist', async () => {
seedMediaItem();
const result = await engine.deleteMediaTranslation('media-1', 'fr');
expect(result).toBe(false);
});
});
describe('availableLanguages on media', () => {
it('includes canonical language and translation languages', async () => {
seedMediaItem({ language: 'en' });
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
const result = await engine.getMedia('media-1');
expect(result?.availableLanguages).toEqual(['en', 'fr']);
});
it('returns empty array when no language is set and no translations exist', async () => {
seedMediaItem();
const result = await engine.getMedia('media-1');
expect(result?.availableLanguages).toEqual([]);
});
});
describe('translated sidecar I/O', () => {
it('writes a translated sidecar file with language suffix', async () => {
seedMediaItem({ language: 'en' });
await engine.upsertMediaTranslation('media-1', 'fr', {
title: 'Une photo',
alt: 'Texte alt',
caption: 'Légende photo',
});
// Verify a sidecar file was written at the .fr.meta path
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.fr.meta';
expect(mockFiles.has(sidecarPath)).toBe(true);
const content = mockFiles.get(sidecarPath)!;
expect(content).toContain('language: fr');
expect(content).toContain('title: "Une photo"');
expect(content).toContain('alt: "Texte alt"');
});
it('reads a translated sidecar file', async () => {
const sidecarContent = [
'---',
'translationFor: media-1',
'language: de',
'title: "Ein Foto"',
'alt: "Alt-Text"',
'caption: "Bildunterschrift"',
'---',
].join('\n');
mockFiles.set('/tmp/project-1/media/2024/01/media-1.jpg.de.meta', sidecarContent);
const result = await engine.readTranslatedSidecarFile(
'/tmp/project-1/media/2024/01/media-1.jpg.de.meta'
);
expect(result).toMatchObject({
translationFor: 'media-1',
language: 'de',
title: 'Ein Foto',
alt: 'Alt-Text',
caption: 'Bildunterschrift',
});
});
it('returns null for non-existent sidecar', async () => {
const result = await engine.readTranslatedSidecarFile(
'/tmp/nonexistent.fr.meta'
);
expect(result).toBeNull();
});
});
describe('canonical sidecar includes language', () => {
it('includes language field in sidecar when set on media', async () => {
seedMediaItem({ language: 'en' });
// Trigger a sidecar write via updateMedia
await engine.updateMedia('media-1', { language: 'en' });
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.meta';
if (mockFiles.has(sidecarPath)) {
const content = mockFiles.get(sidecarPath)!;
expect(content).toContain('language: en');
}
});
});
describe('deleteMedia cascades to translations', () => {
it('deletes all translations when media is deleted', async () => {
seedMediaItem();
mockTranslations.set('trans-fr', {
id: 'trans-fr',
projectId: 'project-1',
translationFor: 'media-1',
language: 'fr',
title: 'Une photo',
alt: null,
caption: null,
createdAt: new Date('2024-01-15T12:00:00Z'),
updatedAt: new Date('2024-01-15T12:00:00Z'),
});
await engine.deleteMedia('media-1');
// Translations should be cleaned up
expect(mockTranslations.size).toBe(0);
});
});
});

View File

@@ -123,12 +123,19 @@ vi.mock('gray-matter', () => ({
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim();
let value = line.slice(colonIndex + 1).trim();
let value: any = line.slice(colonIndex + 1).trim();
// Parse arrays
if (value.startsWith('[') && value.endsWith(']')) {
value = JSON.parse(value.replace(/'/g, '"'));
}
// Parse booleans
else if (value === 'true') {
value = true;
}
else if (value === 'false') {
value = false;
}
// Parse strings
else if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
@@ -160,13 +167,17 @@ vi.mock('../../src/main/engine/TaskManager', () => ({
// Track the mock function for PostEngine.syncPublishedPostFile
const mockSyncPublishedPostFile = vi.fn(async () => true);
const mockSyncPublishedPostTranslationFile = vi.fn(async () => true);
const mockImportOrphanFile = vi.fn(async () => ({ id: 'imported-id', title: 'Imported' }));
const mockImportOrphanTranslationFile = vi.fn(async () => ({ id: 'imported-translation-id', title: 'Imported translation' }));
// Mock PostEngine
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
syncPublishedPostFile: mockSyncPublishedPostFile,
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
importOrphanFile: mockImportOrphanFile,
importOrphanTranslationFile: mockImportOrphanTranslationFile,
})),
}));
@@ -179,10 +190,23 @@ describe('MetadataDiffEngine', () => {
mockPostsGetQueue = [];
mockFileData.clear();
mockAllPostsRows = [];
mockSyncPublishedPostFile.mockClear();
mockImportOrphanFile.mockClear();
mockLocalDb.select.mockImplementation(() => createSelectChain(mockAllPostsRows));
mockLocalDb.update.mockImplementation(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
}) as any);
mockSyncPublishedPostFile.mockReset().mockResolvedValue(true);
mockSyncPublishedPostTranslationFile.mockReset().mockResolvedValue(true);
mockImportOrphanFile.mockReset().mockResolvedValue({ id: 'imported-id', title: 'Imported' });
mockImportOrphanTranslationFile.mockReset().mockResolvedValue({ id: 'imported-translation-id', title: 'Imported translation' });
resetMockCounters();
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile, importOrphanFile: mockImportOrphanFile } as any);
engine = new MetadataDiffEngine({
syncPublishedPostFile: mockSyncPublishedPostFile,
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
importOrphanFile: mockImportOrphanFile,
importOrphanTranslationFile: mockImportOrphanTranslationFile,
} as any);
engine.setProjectContext('test-project');
});
@@ -498,38 +522,48 @@ Content here`);
describe('scanAllPublishedPosts', () => {
it('should scan all published posts and return differences', async () => {
// Mock the raw SQL query that returns published posts
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{
id: 'post-1',
title: 'Post 1',
slug: 'post-1',
file_path: '/mock/userData/posts/2024/01/post-1.md',
tags: '["new-tag"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
{
id: 'post-2',
title: 'Post 2',
slug: 'post-2',
file_path: '/mock/userData/posts/2024/01/post-2.md',
tags: '["tag1"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
],
});
// Mock the second query that gets ALL post file paths (for orphan detection)
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
],
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
return {
rows: [
{
id: 'post-1',
title: 'Post 1',
slug: 'post-1',
file_path: '/mock/userData/posts/2024/01/post-1.md',
tags: '["new-tag"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
{
id: 'post-2',
title: 'Post 2',
slug: 'post-2',
file_path: '/mock/userData/posts/2024/01/post-2.md',
tags: '["tag1"]',
categories: '["cat1"]',
excerpt: null,
author: null,
},
],
};
}
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
return { rows: [] };
}
if (query.sql.includes('SELECT file_path FROM posts')) {
return {
rows: [
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
],
};
}
if (query.sql.includes('SELECT file_path FROM post_translations')) {
return { rows: [] };
}
return { rows: [] };
});
// Queue the posts for sequential .get() calls in comparePostMetadata
@@ -545,6 +579,7 @@ Content here`);
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
},
{
id: 'post-2',
@@ -557,6 +592,7 @@ Content here`);
categories: '["cat1"]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
},
];
@@ -734,6 +770,93 @@ Content`);
// Draft post file should NOT be flagged as orphan since it's in the DB
expect(result.orphanFiles).toEqual([]);
});
it('should include published translation metadata differences in the scan', async () => {
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
return { rows: [] };
}
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
return {
rows: [
{
id: 'translation-1',
translation_for: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
file_path: '/mock/posts/2024/01/post-1.de.md',
},
],
};
}
if (query.sql.includes('SELECT file_path FROM posts')) {
return { rows: [] };
}
if (query.sql.includes('SELECT file_path FROM post_translations')) {
return { rows: [{ file_path: '/mock/posts/2024/01/post-1.de.md' }] };
}
return { rows: [] };
});
let selectCall = 0;
mockLocalDb.select.mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockImplementation(() => {
selectCall += 1;
if (selectCall === 1) return Promise.resolve(undefined);
if (selectCall === 2) {
return Promise.resolve({
id: 'translation-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
content: null,
status: 'published',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
filePath: '/mock/posts/2024/01/post-1.de.md',
});
}
return Promise.resolve({
id: 'post-1',
projectId: 'test-project',
title: 'Hello World',
slug: 'post-1',
status: 'published',
filePath: '/mock/posts/2024/01/post-1.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
});
}),
});
return chain;
});
mockFileData.set('/mock/posts/2024/01/post-1.de.md', `---
translationFor: post-1
language: de
title: "Hallo Welt"
excerpt: "File excerpt"
---
Translated content`);
const result = await engine.scanAllPublishedPosts((current, total) => {}, '/mock/posts');
expect(result.totalScanned).toBe(1);
expect(result.postsWithDifferences).toBe(1);
expect(result.differences).toHaveLength(1);
expect(result.differences[0].postId).toBe('translation-1');
expect(result.differences[0].differences.excerpt).toEqual({ dbValue: 'DB excerpt', fileValue: 'File excerpt' });
expect(result.orphanFiles).toEqual([]);
});
});
describe('importOrphanFiles', () => {
@@ -778,6 +901,22 @@ Content`);
expect(progressCalls[0][0]).toBe(5);
expect(progressCalls[0][1]).toBe(5);
});
it('should import orphan translation files into the translations table path', async () => {
mockFileData.set('/posts/2024/01/post.de.md', `---
translationFor: post-1
language: de
title: "Hallo Welt"
excerpt: "Translated excerpt"
---
Translated content`);
const result = await engine.importOrphanFiles(['/posts/2024/01/post.de.md']);
expect(result).toEqual({ success: 1, failed: 0 });
expect(mockImportOrphanTranslationFile).toHaveBeenCalledWith('/posts/2024/01/post.de.md');
expect(mockImportOrphanFile).not.toHaveBeenCalled();
});
});
describe('groupDifferencesByField', () => {
@@ -856,12 +995,26 @@ Content`);
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('sync failure'));
mockSyncPublishedPostTranslationFile
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
const result = await engine.syncDbToFile(['post-1', 'post-2', 'post-3']);
expect(result).toEqual({ success: 1, failed: 2 });
expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(3);
});
it('should fall back to syncing published translation files when the post file sync does not apply', async () => {
mockSyncPublishedPostFile.mockResolvedValueOnce(false);
mockSyncPublishedPostTranslationFile.mockResolvedValueOnce(true);
const result = await engine.syncDbToFile(['translation-1']);
expect(result).toEqual({ success: 1, failed: 0 });
expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('translation-1');
expect(mockSyncPublishedPostTranslationFile).toHaveBeenCalledWith('translation-1');
});
});
describe('syncFileToDb', () => {
@@ -1019,6 +1172,49 @@ Content here`);
expect(result).toEqual({ success: 1, failed: 2 });
expect(mockLocalDb.update).toHaveBeenCalledTimes(1);
});
it('should sync published translation metadata from file to database', async () => {
let selectCall = 0;
mockLocalDb.select.mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockImplementation(() => {
selectCall += 1;
if (selectCall === 1) return Promise.resolve(undefined);
return Promise.resolve({
id: 'translation-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Hallo Welt',
excerpt: 'DB excerpt',
status: 'published',
filePath: '/mock/userData/posts/2024/01/post-1.de.md',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
});
}),
});
return chain;
});
mockFileData.set('/mock/userData/posts/2024/01/post-1.de.md', `---
translationFor: post-1
language: de
title: "Hallo Datei"
excerpt: "File excerpt"
---
Translated content`);
await engine.syncFileToDb(['translation-1'], 'title');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.title).toBe('Hallo Datei');
});
});
// ── Media diff tests ──
@@ -1383,4 +1579,595 @@ Content here`);
});
});
});
describe('comparePostMetadata new fields', () => {
it('should detect doNotTranslate differences between DB and file', async () => {
const dbPost = {
id: 'post-dnt',
projectId: 'test-project',
title: 'DNT Post',
slug: 'dnt-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/dnt-post.md',
tags: '[]',
categories: '[]',
doNotTranslate: true,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-dnt', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/dnt-post.md', `---
id: post-dnt
projectId: test-project
title: "DNT Post"
slug: dnt-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-dnt');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.doNotTranslate).toBeDefined();
expect(result?.differences.doNotTranslate?.dbValue).toBe(true);
expect(result?.differences.doNotTranslate?.fileValue).toBe(false);
});
it('should detect templateSlug differences between DB and file', async () => {
const dbPost = {
id: 'post-tpl',
projectId: 'test-project',
title: 'Template Post',
slug: 'template-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/template-post.md',
tags: '[]',
categories: '[]',
templateSlug: 'blog-default',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-tpl', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/template-post.md', `---
id: post-tpl
projectId: test-project
title: "Template Post"
slug: template-post
status: published
templateSlug: old-template
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-tpl');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.templateSlug).toBeDefined();
expect(result?.differences.templateSlug?.dbValue).toBe('blog-default');
expect(result?.differences.templateSlug?.fileValue).toBe('old-template');
});
it('should detect status differences between DB and file', async () => {
const dbPost = {
id: 'post-status',
projectId: 'test-project',
title: 'Archived Post',
slug: 'archived-post',
status: 'archived',
filePath: '/mock/userData/posts/2024/01/archived-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-status', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/archived-post.md', `---
id: post-status
projectId: test-project
title: "Archived Post"
slug: archived-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-status');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.status).toBeDefined();
expect(result?.differences.status?.dbValue).toBe('archived');
expect(result?.differences.status?.fileValue).toBe('published');
});
it('should show no differences when new fields match', async () => {
const dbPost = {
id: 'post-match',
projectId: 'test-project',
title: 'Matching Post',
slug: 'matching-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/matching-post.md',
tags: '[]',
categories: '[]',
doNotTranslate: false,
templateSlug: null,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-match', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/matching-post.md', `---
id: post-match
projectId: test-project
title: "Matching Post"
slug: matching-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-match');
expect(result?.hasDifferences).toBe(false);
});
});
describe('syncFileToDb new fields', () => {
it('should sync doNotTranslate from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
doNotTranslate: true
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'doNotTranslate');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.doNotTranslate).toBe(true);
});
it('should sync templateSlug from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
templateSlug: my-template
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'templateSlug');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.templateSlug).toBe('my-template');
});
it('should sync status from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: archived
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'status');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.status).toBe('archived');
});
});
describe('groupDifferencesByField new fields', () => {
it('should include new fields in group labels', () => {
const diffs: PostMetadataDiff[] = [
{
postId: 'p1',
title: 'Post 1',
slug: 'post-1',
hasDifferences: true,
differences: {
doNotTranslate: { dbValue: true, fileValue: false },
templateSlug: { dbValue: 'tpl-a', fileValue: 'tpl-b' },
status: { dbValue: 'published', fileValue: 'archived' },
},
},
];
const groups = engine.groupDifferencesByField(diffs);
const fieldNames = groups.map(g => g.field);
expect(fieldNames).toContain('doNotTranslate');
expect(fieldNames).toContain('templateSlug');
expect(fieldNames).toContain('status');
const dntGroup = groups.find(g => g.field === 'doNotTranslate');
expect(dntGroup?.label).toBe('Do Not Translate');
const tplGroup = groups.find(g => g.field === 'templateSlug');
expect(tplGroup?.label).toBe('Template');
const statusGroup = groups.find(g => g.field === 'status');
expect(statusGroup?.label).toBe('Status');
});
it('should include timestamp fields in group labels', () => {
const diffs: PostMetadataDiff[] = [
{
postId: 'p1',
title: 'Post 1',
slug: 'post-1',
hasDifferences: true,
differences: {
createdAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-02-15T00:00:00.000Z' },
updatedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-03-01T00:00:00.000Z' },
publishedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '' },
},
},
];
const groups = engine.groupDifferencesByField(diffs);
const fieldNames = groups.map(g => g.field);
expect(fieldNames).toContain('createdAt');
expect(fieldNames).toContain('updatedAt');
expect(fieldNames).toContain('publishedAt');
expect(groups.find(g => g.field === 'createdAt')?.label).toBe('Created At');
expect(groups.find(g => g.field === 'updatedAt')?.label).toBe('Updated At');
expect(groups.find(g => g.field === 'publishedAt')?.label).toBe('Published At');
});
});
describe('comparePostMetadata timestamp diffs', () => {
it('should detect createdAt differences between DB and file', async () => {
const dbPost = {
id: 'post-ts',
projectId: 'test-project',
title: 'Timestamp Post',
slug: 'timestamp-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/timestamp-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
};
mockPosts.set('post-ts', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/timestamp-post.md', `---
id: post-ts
projectId: test-project
title: "Timestamp Post"
slug: timestamp-post
status: published
tags: []
categories: []
createdAt: 2024-06-01T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-ts');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.createdAt).toBeDefined();
expect(result?.differences.createdAt?.fileValue).toContain('2024-06-01');
});
it('should detect publishedAt differences when DB has it but file does not', async () => {
const dbPost = {
id: 'post-pa',
projectId: 'test-project',
title: 'Published At Post',
slug: 'published-at-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-at-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
};
mockPosts.set('post-pa', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-at-post.md', `---
id: post-pa
projectId: test-project
title: "Published At Post"
slug: published-at-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-pa');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.publishedAt).toBeDefined();
});
it('should show no timestamp differences when they match at second precision', async () => {
const dbPost = {
id: 'post-eq',
projectId: 'test-project',
title: 'Equal Post',
slug: 'equal-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/equal-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15T12:30:00.000Z'),
updatedAt: new Date('2024-01-15T12:30:00.000Z'),
publishedAt: new Date('2024-01-15T12:30:00.000Z'),
};
mockPosts.set('post-eq', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/equal-post.md', `---
id: post-eq
projectId: test-project
title: "Equal Post"
slug: equal-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T12:30:00.000Z
updatedAt: 2024-01-15T12:30:00.000Z
publishedAt: 2024-01-15T12:30:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-eq');
expect(result?.hasDifferences).toBe(false);
expect(result?.differences.createdAt).toBeUndefined();
expect(result?.differences.updatedAt).toBeUndefined();
expect(result?.differences.publishedAt).toBeUndefined();
});
});
describe('syncFileToDb timestamp fields', () => {
it('should sync createdAt from file to database using file value', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: []
createdAt: 2024-06-01T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'createdAt');
expect(mockLocalDb.update).toHaveBeenCalled();
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.createdAt).toBeInstanceOf(Date);
expect(setCall.createdAt.toISOString()).toContain('2024-06-01');
// Should NOT auto-set updatedAt when syncing a timestamp field
expect(setCall.updatedAt).toBeUndefined();
});
it('should auto-set updatedAt when syncing a non-timestamp field', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: ["new-tag"]
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'tags');
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
// Should auto-set updatedAt for non-timestamp field syncs
expect(setCall.updatedAt).toBeInstanceOf(Date);
});
});
describe('compareMediaMetadata language diff', () => {
let mediaEngine: MetadataDiffEngine;
const mockReadSidecarFile = vi.fn();
beforeEach(() => {
mediaEngine = new MetadataDiffEngine(
undefined,
{ readSidecarFile: mockReadSidecarFile, getMedia: vi.fn(), updateMedia: vi.fn() } as any,
);
mediaEngine.setProjectContext('test-project');
});
it('should detect media language differences', async () => {
mockPostsGetQueue = [{
id: 'media-1',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Photo',
alt: 'A photo',
caption: '',
author: '',
language: 'en',
tags: '[]',
}];
mockReadSidecarFile.mockResolvedValueOnce({
id: 'media-1',
originalName: 'photo.jpg',
title: 'Photo',
alt: 'A photo',
caption: '',
author: '',
language: 'fr',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-1');
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.language).toBeDefined();
expect(result?.differences.language?.dbValue).toBe('en');
expect(result?.differences.language?.fileValue).toBe('fr');
});
it('should not flag language when both are empty', async () => {
mockPostsGetQueue = [{
id: 'media-2',
projectId: 'test-project',
originalName: 'photo.jpg',
filePath: '/mock/media/photo.jpg',
title: 'Photo',
alt: '',
caption: '',
author: '',
language: null,
tags: '[]',
}];
mockReadSidecarFile.mockResolvedValueOnce({
title: 'Photo',
alt: '',
caption: '',
author: '',
language: '',
tags: [],
});
const result = await mediaEngine.compareMediaMetadata('media-2');
expect(result?.differences.language).toBeUndefined();
});
});
});

View File

@@ -109,7 +109,7 @@ describe('replaceAllMacrosAsync', () => {
expect(result).toContain('<aside class="custom-box">Custom Content</aside>');
});
it('returns empty string for unknown macros without Python renderer', async () => {
it('preserves unknown macros without Python renderer', async () => {
const result = await replaceAllMacrosAsync(
'Before [[unknown_macro]] After',
'post-1',
@@ -120,10 +120,10 @@ describe('replaceAllMacrosAsync', () => {
null,
);
expect(result).toBe('Before After');
expect(result).toBe('Before [[unknown_macro]] After');
});
it('returns empty string for unmatched Python macros', async () => {
it('preserves unmatched Python macros', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([]),
renderMacro: vi.fn(),
@@ -139,7 +139,7 @@ describe('replaceAllMacrosAsync', () => {
mockRenderer,
);
expect(result).toBe('Before After');
expect(result).toBe('Before [[nonexistent_macro]] After');
expect(mockRenderer.renderMacro).not.toHaveBeenCalled();
});
@@ -186,7 +186,21 @@ describe('replaceAllMacrosAsync', () => {
mockRenderer,
);
expect(result).toBe('Before After');
expect(result).toBe('Before [[my_macro]] After');
});
it('preserves the original unknown macro tag including params', async () => {
const result = await replaceAllMacrosAsync(
'Before [[unknown_macro title="Hello" count="2"]] After',
'post-1',
[],
null,
[],
'en',
null,
);
expect(result).toBe('Before [[unknown_macro title="Hello" count="2"]] After');
});
it('does not look up Python scripts when all macros are built-in', async () => {
@@ -244,6 +258,73 @@ describe('replaceAllMacrosAsync', () => {
expect(call.cacheKey).toBe('ctx-script:2');
});
it('passes languagePrefix and translations in Python macro context', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'lang-script',
slug: 'lang_test',
entrypoint: 'render',
content: 'def render(ctx, post): return {"html": "ok"}',
version: 1,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
};
await replaceAllMacrosAsync(
'[[lang_test]]',
'post-1',
[],
null,
[],
'fr',
mockRenderer,
null,
'/fr',
);
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
const parsedContext = JSON.parse(call.contextJson);
expect(parsedContext.env.languagePrefix).toBe('/fr');
expect(parsedContext.env.mainLanguage).toBe('fr');
expect(parsedContext.env.translations).toBeDefined();
expect(typeof parsedContext.env.translations).toBe('object');
expect(parsedContext.env.translations['render.archive']).toBe('Archives');
});
it('passes empty languagePrefix when not provided', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'no-prefix-script',
slug: 'no_prefix',
entrypoint: 'render',
content: 'def render(ctx, post): return {"html": "ok"}',
version: 1,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
};
await replaceAllMacrosAsync(
'[[no_prefix]]',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
const parsedContext = JSON.parse(call.contextJson);
expect(parsedContext.env.languagePrefix).toBe('');
expect(parsedContext.env.translations).toBeDefined();
});
it('returns unchanged text when there are no macros', async () => {
const content = 'Just plain text with no macros';
const result = await replaceAllMacrosAsync(content, '', [], null, [], 'en', null);

View File

@@ -7,6 +7,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PostEngine, PostData } from '../../src/main/engine/PostEngine';
import { postTranslations } from '../../src/main/database/schema';
import { resetMockCounters } from '../utils/factories';
import * as fs from 'fs/promises';
@@ -1838,6 +1839,71 @@ Content`);
expect(insertedProjects).toHaveLength(1);
expect(insertedProjects[0]).toBe('current-project-id');
});
it('should rebuild published translation files into the translations table', async () => {
const fs = await import('fs/promises');
const insertedRows: Array<{ table: unknown; data: any }> = [];
vi.mocked(mockLocalDb.insert).mockImplementation((table: unknown) => ({
values: vi.fn((data: any) => {
insertedRows.push({ table, data });
if (data && data.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
}) as any);
vi.mocked(fs.readdir).mockResolvedValueOnce([
mockDirent('source-post.md'),
mockDirent('source-post.de.md'),
] as any);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
if (filePath.includes('source-post.md') && !filePath.includes('source-post.de.md')) {
return `---
id: source-post-id
projectId: default
title: Source Post
slug: source-post
status: published
language: en
createdAt: 2024-01-01T00:00:00.000Z
updatedAt: 2024-01-02T00:00:00.000Z
publishedAt: 2024-01-02T00:00:00.000Z
tags: []
categories: []
---
Canonical content`;
}
if (filePath.includes('source-post.de.md')) {
return `---
translationFor: source-post-id
language: de
title: Quellbeitrag
excerpt: Deutsche Zusammenfassung
---
Deutscher Inhalt`;
}
throw new Error('ENOENT');
});
await postEngine.rebuildDatabaseFromFiles();
const translationInsert = insertedRows.find((row) => row.table === postTranslations);
expect(translationInsert).toBeDefined();
expect(translationInsert?.data).toMatchObject({
projectId: 'default',
translationFor: 'source-post-id',
language: 'de',
title: 'Quellbeitrag',
excerpt: 'Deutsche Zusammenfassung',
status: 'published',
filePath: expect.stringContaining('source-post.de.md'),
});
});
});
describe('Date-based folder structure', () => {
@@ -3787,4 +3853,198 @@ Body.`);
expect(result!.slug).toBe('existing-slug-2');
});
});
describe('FTS translation indexing', () => {
it('should include translation content in FTS index when updating a post', async () => {
// Arrange: set up a post with a German translation
postEngine.setMainLanguage('en');
postEngine.setSearchLanguage('english');
// Mock getTranslationRowsForPost to return a translation
const translationRow = {
id: 'trans-1',
projectId: 'default',
translationFor: 'post-1',
language: 'de',
title: 'German Title Häuser',
excerpt: 'German Excerpt',
content: 'German draft content Haus',
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: '',
checksum: null,
};
// getAllTranslationRows returns all translations for the current project
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.all = vi.fn().mockResolvedValue([translationRow]);
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue(undefined),
all: vi.fn().mockResolvedValue([translationRow]),
});
return chain;
});
mockExecuteArgs = [];
// Act
await postEngine.updateFTSIndex({
id: 'post-1',
projectId: 'test-project',
title: 'English Title',
content: 'English content about houses',
excerpt: 'Summary',
tags: ['test'],
categories: ['blog'],
});
// Assert: the FTS insert should contain both English and German stemmed content
const ftsInsert = mockExecuteArgs.find((q: { sql: string }) =>
q.sql.includes('INSERT INTO posts_fts'),
);
expect(ftsInsert).toBeDefined();
const indexedContent = ftsInsert.args[2] as string;
// English content should be stemmed with English stemmer
expect(indexedContent).toContain('hous'); // "houses" stemmed in English
// German content should be stemmed with German stemmer
expect(indexedContent).toContain('haus'); // "Haus/Häuser" stemmed in German
});
it('should re-index FTS when a translation is created', async () => {
// Arrange: source post exists
const sourcePost = {
id: 'post-1',
projectId: 'test-project',
title: 'Source Post',
slug: 'source-post',
excerpt: null,
content: 'Source content',
status: 'draft',
author: null,
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: null,
checksum: null,
tags: '[]',
categories: '[]',
language: 'en',
translationOfId: null,
templateSlug: null,
doNotTranslate: 0,
version: 1,
stemmedTitle: '',
stemmedContent: '',
};
let selectCallCount = 0;
vi.mocked(mockLocalDb.select).mockImplementation(() => {
selectCallCount++;
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue(selectCallCount <= 2 ? sourcePost : undefined),
all: vi.fn().mockResolvedValue(selectCallCount <= 2 ? [sourcePost] : []),
});
chain.all = vi.fn().mockResolvedValue([]);
return chain;
});
mockExecuteArgs = [];
// Act: create a French translation
await postEngine.upsertPostTranslation('post-1', 'fr', {
title: 'Titre Français',
content: 'Contenu en français avec des maisons',
});
// Assert: FTS should have been updated (at least one INSERT INTO posts_fts)
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
q.sql.includes('INSERT INTO posts_fts'),
);
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
});
it('should stem search query with multiple languages for cross-language matching', async () => {
postEngine.setMainLanguage('en');
postEngine.setSearchLanguage('english');
// Mock translations with German language to simulate a project with translations
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.all = vi.fn().mockResolvedValue([
{ id: 'trans-1', projectId: 'test-project', translationFor: 'post-1', language: 'de', title: 'T', content: 'C', status: 'draft', createdAt: new Date(), updatedAt: new Date(), publishedAt: null, filePath: '', checksum: null },
]);
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({ id: 'post-1', title: 'Found', slug: 'found', excerpt: null, tags: '[]', categories: '[]' }),
});
return chain;
});
mockLocalClient.execute.mockResolvedValueOnce({ rows: [{ id: 'post-1' }] });
await postEngine.searchPosts('Häuser');
// Verify the FTS MATCH query was called
const matchCall = mockLocalClient.execute.mock.calls[0]?.[0] as { sql: string; args: any[] };
expect(matchCall.sql).toContain('MATCH');
// The query should contain stems from multiple languages combined with OR
const matchArg = matchCall.args[1] as string;
expect(matchArg).toBeDefined();
});
it('should rebuild FTS index including translation content', async () => {
postEngine.setMainLanguage('en');
postEngine.setSearchLanguage('english');
const translationRow = {
id: 'trans-1',
projectId: 'test-project',
translationFor: 'post-1',
language: 'de',
title: 'Deutscher Titel',
excerpt: null,
content: 'Deutscher Inhalt',
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: '',
checksum: null,
};
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
orderBy: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([
{ id: 'post-1', projectId: 'test-project', title: 'English Post', content: 'English content', tags: '[]', categories: '[]', language: 'en' },
]),
get: vi.fn().mockResolvedValue(undefined),
});
chain.all = vi.fn().mockResolvedValue([translationRow]);
return chain;
});
mockExecuteArgs = [];
await postEngine.rebuildFTSIndex();
// Verify FTS was populated
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
q.sql.includes('INSERT INTO posts_fts'),
);
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
// The indexed content should include German translation content
const insertContent = ftsInserts[0]?.args?.[2] as string;
expect(insertContent).toBeDefined();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ type PostEngineLike = {
};
type SettingsEngineLike = {
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string } | null>;
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string; blogLanguages?: string[] } | null>;
setProjectContext: (projectId: string, dataDir?: string) => void;
};
@@ -39,6 +39,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
@@ -463,7 +464,6 @@ describe('PreviewServer', () => {
postMediaEngine,
settingsEngine: settingsEngine as any,
menuEngine,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
});
@@ -618,6 +618,142 @@ describe('PreviewServer', () => {
expect(draftHtml).not.toContain('Published body');
});
it('serves translated draft content for single post route when lang query param is provided', async () => {
const draftPost = makePost({
id: 'post-2',
slug: 'shared-slug',
title: 'Draft Title',
content: 'Draft-only body',
status: 'draft',
language: 'en',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
});
server = new PreviewServer({
postEngine: {
...makeEngine([draftPost]),
async getPostTranslation(postId: string, language: string) {
if (postId === 'post-2' && language === 'fr') {
return {
id: 'translation-2-fr',
translationFor: 'post-2',
language: 'fr',
title: 'Titre brouillon',
excerpt: 'Resume brouillon',
content: 'Contenu brouillon traduit',
status: 'draft',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
publishedAt: null,
filePath: 'posts/shared-slug.fr.md',
};
}
return null;
},
} as any,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const draftResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug?draft=true&postId=post-2&lang=fr`);
expect(draftResponse.status).toBe(200);
const draftHtml = await draftResponse.text();
expect(draftHtml).toContain('Titre brouillon');
expect(draftHtml).toContain('Contenu brouillon traduit');
expect(draftHtml).toContain('<html lang="fr"');
expect(draftHtml).not.toContain('Draft-only body');
});
it('prefers project main language content for canonical single post route and falls back to canonical content when unavailable', async () => {
const publishedPost = makePost({
id: 'post-1',
slug: 'shared-slug',
title: 'Published Title',
content: 'Published body',
status: 'published',
language: 'en',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
});
server = new PreviewServer({
postEngine: {
...makeEngine([publishedPost]),
async getPostTranslation(postId: string, language: string) {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1-fr',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Titre publie',
excerpt: 'Resume FR',
content: 'Contenu FR',
status: 'published',
createdAt: new Date('2025-01-03T10:00:00.000Z'),
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
publishedAt: new Date('2025-01-03T11:00:00.000Z'),
filePath: 'posts/shared-slug.fr.md',
};
}
return null;
},
} as any,
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const translatedResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
expect(translatedResponse.status).toBe(200);
const translatedHtml = await translatedResponse.text();
expect(translatedHtml).toContain('<html lang="fr"');
expect(translatedHtml).toContain('Titre publie');
expect(translatedHtml).toContain('Contenu FR');
expect(translatedHtml).not.toContain('Published body');
server = new PreviewServer({
postEngine: {
...makeEngine([publishedPost]),
async getPostTranslation() {
return null;
},
} as any,
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const fallbackResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
expect(fallbackResponse.status).toBe(200);
const fallbackHtml = await fallbackResponse.text();
expect(fallbackHtml).toContain('Published Title');
expect(fallbackHtml).toContain('Published body');
expect(fallbackHtml).not.toContain('Contenu FR');
});
it('uses selected pico theme stylesheet from project metadata', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
@@ -674,11 +810,87 @@ describe('PreviewServer', () => {
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('<html lang="en" data-theme="dark">');
expect(html).toContain('<html lang="en" data-language-prefix="" data-theme="dark">');
expect(html).toContain('href="/assets/pico.green.min.css"');
expect(html).toContain('/assets/bds.css');
});
it('renders language switcher with flags on style preview when blogLanguages configured', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return {
maxPostsPerPage: 50,
mainLanguage: 'en',
blogLanguages: ['en', 'de'],
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/__style-preview`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('class="language-switcher"');
expect(html).toContain('🇬🇧');
expect(html).toContain('🇩🇪');
});
it('includes mainLanguage in language switcher even when not listed in blogLanguages', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: {
setProjectContext: vi.fn(),
async getProjectMetadata() {
return {
maxPostsPerPage: 50,
mainLanguage: 'de',
blogLanguages: ['en'],
};
},
} as any,
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
// Check style-preview route
const styleResponse = await fetch(`${server.getBaseUrl()}/__style-preview`);
expect(styleResponse.status).toBe(200);
const styleHtml = await styleResponse.text();
expect(styleHtml).toContain('class="language-switcher"');
expect(styleHtml).toContain('🇩🇪');
expect(styleHtml).toContain('🇬🇧');
// Check root route
const rootResponse = await fetch(`${server.getBaseUrl()}/`);
expect(rootResponse.status).toBe(200);
const rootHtml = await rootResponse.text();
expect(rootHtml).toContain('class="language-switcher"');
expect(rootHtml).toContain('🇩🇪');
expect(rootHtml).toContain('🇬🇧');
// Check language-prefixed route (/en/)
const enResponse = await fetch(`${server.getBaseUrl()}/en/`);
expect(enResponse.status).toBe(200);
const enHtml = await enResponse.text();
expect(enHtml).toContain('class="language-switcher"');
expect(enHtml).toContain('🇩🇪');
expect(enHtml).toContain('🇬🇧');
});
it('limits list routes to 50 posts', async () => {
const posts = Array.from({ length: 60 }).map((_, index) =>
makePost({
@@ -1179,7 +1391,7 @@ describe('PreviewServer', () => {
await server.start(0);
const monthPageHtml = await (await fetch(`${server.getBaseUrl()}/2020/2/`)).text();
expect(monthPageHtml).toContain('<html lang="fr">');
expect(monthPageHtml).toContain('<html lang="fr"');
expect(monthPageHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
expect(monthPageHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
});
@@ -1579,7 +1791,7 @@ describe('PreviewServer', () => {
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('<html lang="de">');
expect(html).toContain('<html lang="de"');
});
it('initializes metadata before reading language when supported by settings engine', async () => {
@@ -1610,7 +1822,7 @@ describe('PreviewServer', () => {
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('<html lang="fr">');
expect(html).toContain('<html lang="fr"');
});
it('falls back to active project name in page title when metadata is unavailable', async () => {
@@ -1692,7 +1904,7 @@ describe('PreviewServer', () => {
postMediaEngine: makePostMediaEngine({}) as any,
settingsEngine: makeSettings(50),
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
});
await server.start(0);
@@ -2068,4 +2280,59 @@ describe('PreviewServer', () => {
expect(html).toContain('data-template="not-found"');
expect(html).toContain('class="not-found"');
});
it('returns 503 after stop is called', async () => {
server = new PreviewServer({
postEngine: makeEngine([makePost()]),
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const baseUrl = server.getBaseUrl();
const okResponse = await fetch(`${baseUrl}/`);
expect(okResponse.status).toBe(200);
await server.stop();
await expect(fetch(`${baseUrl}/`)).rejects.toThrow();
});
it('includes translation variant slugs in rewrite context using batch method', async () => {
const publishedPost = makePost({
id: 'post-1',
slug: 'hello',
status: 'published',
language: 'en',
createdAt: new Date('2025-02-15T10:00:00.000Z'),
});
const getPublishedTranslationLanguagesByPost = vi.fn(async () => {
const map = new Map<string, string[]>();
map.set('post-1', ['de', 'fr']);
return map;
});
server = new PreviewServer({
postEngine: {
...makeEngine([publishedPost]),
getPublishedTranslationLanguagesByPost,
} as any,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
expect(getPublishedTranslationLanguagesByPost).toHaveBeenCalled();
});
});

View File

@@ -21,6 +21,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
@@ -50,8 +51,8 @@ function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData |
result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
}
if ((filter as any).excludeCategories && (filter as any).excludeCategories.length > 0) {
result = result.filter((post) => !(filter as any).excludeCategories.some((category: string) => post.categories.includes(category)));
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
result = result.filter((post) => !filter.excludeCategories!.some((category: string) => post.categories.includes(category)));
}
if (filter.year !== undefined) {
@@ -116,6 +117,118 @@ describe('SharedSnapshotService', () => {
expect(result?.id).toBe('draft-1');
});
it('returns a translated draft variant when preview language is provided', async () => {
const draft = makePost({ id: 'draft-1', slug: 'my-post', status: 'draft', language: 'en', title: 'Canonical title', content: 'Canonical body', createdAt: new Date('2025-03-21T10:00:00.000Z') });
const engine = {
...makeEngine([draft]),
async getPostTranslation(postId: string, language: string) {
if (postId === 'draft-1' && language === 'fr') {
return {
id: 'translation-1',
translationFor: 'draft-1',
language: 'fr',
title: 'Titre traduit',
excerpt: 'Resume traduit',
content: 'Contenu traduit',
status: 'draft',
createdAt: new Date('2025-03-21T10:00:00.000Z'),
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
publishedAt: null,
filePath: 'posts/my-post.fr.md',
};
}
return null;
},
} as SharedSnapshotPostEngine & {
getPostTranslation: (postId: string, language: string) => Promise<any>;
};
const result = await findSinglePostBySlug(
engine,
'my-post',
{ useDraftContent: true, draftPostId: 'draft-1', lang: 'fr' },
{ year: 2025, month: 3, day: 21 },
);
expect(result).toMatchObject({
id: 'translation-1',
slug: 'my-post.fr',
language: 'fr',
title: 'Titre traduit',
excerpt: 'Resume traduit',
content: 'Contenu traduit',
});
});
it('prefers project main language content and falls back to canonical language when no translation exists', async () => {
const published = makePost({
id: 'post-1',
slug: 'my-post',
status: 'published',
language: 'en',
title: 'Canonical title',
content: 'Canonical body',
createdAt: new Date('2025-03-21T10:00:00.000Z'),
});
const getPostTranslation = vi.fn(async (postId: string, language: string) => {
if (postId === 'post-1' && language === 'fr') {
return {
id: 'translation-1',
projectId: 'default',
translationFor: 'post-1',
language: 'fr',
title: 'Titre principal',
excerpt: 'Resume principal',
content: 'Contenu principal',
status: 'published',
createdAt: new Date('2025-03-21T10:00:00.000Z'),
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
publishedAt: new Date('2025-03-21T11:00:00.000Z'),
filePath: 'posts/my-post.fr.md',
};
}
return null;
});
const engine = {
...makeEngine([published]),
getPostTranslation,
} as SharedSnapshotPostEngine & {
getPostTranslation: (postId: string, language: string) => Promise<any>;
};
const translated = await findSinglePostBySlug(
engine,
'my-post',
{ preferredLanguage: 'fr' },
{ year: 2025, month: 3, day: 21 },
);
expect(translated).toMatchObject({
id: 'translation-1',
slug: 'my-post.fr',
language: 'fr',
title: 'Titre principal',
content: 'Contenu principal',
});
const fallback = await findSinglePostBySlug(
engine,
'my-post',
{ preferredLanguage: 'de' },
{ year: 2025, month: 3, day: 21 },
);
expect(fallback).toMatchObject({
id: 'post-1',
slug: 'my-post',
language: 'en',
title: 'Canonical title',
content: 'Canonical body',
});
expect(getPostTranslation).toHaveBeenNthCalledWith(1, 'post-1', 'fr');
expect(getPostTranslation).toHaveBeenNthCalledWith(2, 'post-1', 'de');
});
it('uses findPublishedBySlug shortcut when present', async () => {
const post = makePost({ id: 'x1', slug: 'shortcut', status: 'published' });
const engine = makeEngine([post]);

View File

@@ -92,4 +92,49 @@ describe('ValidationApplyPlannerService', () => {
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
expect(targeted.requestRootRoutes).toBe(true);
});
it('classifies language-prefixed missing paths into per-language plans', () => {
const plan = planMissingValidationPaths(
[
'/fr/',
'/fr/page/2',
'/fr/category/news',
'/fr/tag/dev',
'/fr/2025/01/15/my-post',
'/fr/2025',
'/fr/2025/01',
'/fr/about',
'/de/',
'/de/category/tech',
],
['fr', 'de'],
);
expect(plan.requiresFallbackSectionRender).toBe(false);
expect(plan.requestRootRoutes).toBe(false);
const frPlan = plan.languagePlans.get('fr');
expect(frPlan).toBeDefined();
expect(frPlan!.requestRootRoutes).toBe(true);
expect(Array.from(frPlan!.requestedCategories)).toEqual(['news']);
expect(Array.from(frPlan!.requestedTags)).toEqual(['dev']);
expect(frPlan!.requestedPostRoutes).toEqual([
{ year: 2025, month: 1, day: 15, slug: 'my-post' },
]);
expect(Array.from(frPlan!.requestedYears)).toContain(2025);
expect(Array.from(frPlan!.requestedYearMonths)).toContain('2025/01');
expect(Array.from(frPlan!.requestedPageSlugs)).toEqual(['about']);
const dePlan = plan.languagePlans.get('de');
expect(dePlan).toBeDefined();
expect(dePlan!.requestRootRoutes).toBe(true);
expect(Array.from(dePlan!.requestedCategories)).toEqual(['tech']);
});
it('treats unknown prefixes as page slugs when no languages specified', () => {
const plan = planMissingValidationPaths(['/fr/category/news', '/fr/']);
expect(plan.languagePlans.size).toBe(0);
expect(plan.requiresFallbackSectionRender).toBe(true);
});
});

View File

@@ -43,6 +43,7 @@ function createMockDeps() {
const postEngine = {
getPost: vi.fn(),
upsertPostTranslation: vi.fn(),
} as any;
return { chatEngine, providers, mediaEngine, postEngine };
@@ -182,3 +183,283 @@ describe('OneShotTasks.analyzePost', () => {
expect(deps.providers.resolveModel).toHaveBeenCalledWith('custom-model-id');
});
});
describe('OneShotTasks.translatePost', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('instructs the AI to leave fenced code blocks untranslated', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Resume"}',
} as any)
.mockResolvedValueOnce({
text: 'Intro\n\n```ts\nconst label = "Hello";\n```',
} as any);
await tasks.translatePost('post-1', 'fr');
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
system: expect.stringContaining('Leave text inside fenced code blocks untranslated.'),
}));
});
it('translates markdown body outside a JSON envelope', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Intro\n\n```ts\nconst config = { hello: "world" };\n```\n\nEnd.',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: 'Bonjour',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Resume"}',
} as any)
.mockResolvedValueOnce({
text: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
} as any);
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
expect(mockGenerateText).toHaveBeenCalledTimes(2);
expect(mockGenerateText).toHaveBeenNthCalledWith(1, expect.objectContaining({
system: expect.stringContaining('keys title and excerpt'),
}));
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
system: expect.stringContaining('Return ONLY the translated Markdown body'),
}));
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
title: 'Mon Post',
excerpt: 'Resume',
content: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
});
});
it('passes the raw markdown body without a leading content label', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: '# Heading\n\nBody text',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: '# Titre\n\nTexte',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Resume"}',
} as any)
.mockResolvedValueOnce({
text: '# Titre\n\nTexte',
} as any);
await tasks.translatePost('post-1', 'fr');
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
prompt: '# Heading\n\nBody text',
}));
});
it('strips an accidental leading content label from translated markdown', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Mein Beitrag',
excerpt: 'Zusammenfassung',
content: '# Uberschrift\n\nText',
language: 'de',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'My Post',
excerpt: 'Summary',
content: '# Heading\n\nText',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"My Post","excerpt":"Summary"}',
} as any)
.mockResolvedValueOnce({
text: 'content:\n\n# Heading\n\nText',
} as any);
await tasks.translatePost('post-1', 'en');
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'en', {
title: 'My Post',
excerpt: 'Summary',
content: '# Heading\n\nText',
});
});
it('instructs the AI to translate only — never invent or add content', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: '# Hello\n\nWorld',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Resume',
content: '# Bonjour\n\nMonde',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"Mon Post","excerpt":"Resume"}' } as any)
.mockResolvedValueOnce({ text: '# Bonjour\n\nMonde' } as any);
await tasks.translatePost('post-1', 'fr');
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
expect(contentSystemPrompt).toMatch(/do not invent|do not add|only translate/i);
expect(contentSystemPrompt).toMatch(/macro|shortcode|non-translatable/i);
});
it('instructs the AI to keep markdown link text unchanged and translate surrounding prose', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'USearch Library',
excerpt: '',
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - drin was drauf steht. Eine Library.',
language: 'de',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'USearch Library',
excerpt: '',
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"USearch Library","excerpt":""}' } as any)
.mockResolvedValueOnce({
text: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
} as any);
await tasks.translatePost('post-1', 'en');
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
expect(contentSystemPrompt).toMatch(/link text/i);
expect(contentSystemPrompt).toMatch(/url/i);
});
it('falls back to source title and excerpt when metadata JSON is invalid', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Intro',
language: 'en',
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'My Post',
excerpt: 'Summary',
content: 'Bonjour',
});
mockGenerateText
.mockResolvedValueOnce({
text: 'not valid json',
} as any)
.mockResolvedValueOnce({
text: 'Bonjour',
} as any);
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
title: 'My Post',
excerpt: 'Summary',
content: 'Bonjour',
});
});
it('passes status published when autoPublish is set', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Hello world',
language: 'en',
status: 'published',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Post',
excerpt: 'Résumé',
content: 'Bonjour le monde',
});
mockGenerateText
.mockResolvedValueOnce({
text: '{"title":"Mon Post","excerpt":"Résumé"}',
} as any)
.mockResolvedValueOnce({
text: 'Bonjour le monde',
} as any);
const result = await tasks.translatePost('post-1', 'fr', { autoPublish: true });
expect(result.success).toBe(true);
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
title: 'Mon Post',
excerpt: 'Résumé',
content: 'Bonjour le monde',
status: 'published',
});
});
});

View File

@@ -0,0 +1,386 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the AI SDK generateText before importing OneShotTasks
vi.mock('ai', () => ({
generateText: vi.fn(),
}));
// Mock i18n
vi.mock('../../../src/main/shared/i18n', () => ({
resolveSupportedRenderLanguage: vi.fn((lang: string) => lang),
translateRender: vi.fn((_lang: string, key: string) => key),
}));
import { OneShotTasks } from '../../../src/main/engine/ai/tasks';
import { generateText } from 'ai';
const mockGenerateText = vi.mocked(generateText);
function createMockDeps() {
const chatEngine = {
getSetting: vi.fn().mockResolvedValue(null),
} as any;
const providers = {
detectModelProvider: vi.fn().mockReturnValue('opencode'),
isProviderKeySet: vi.fn().mockReturnValue(true),
getOpencodeKey: vi.fn().mockReturnValue('test-key'),
getMistralKey: vi.fn().mockReturnValue(null),
resolveModel: vi.fn().mockReturnValue('mock-model'),
isOfflineMode: vi.fn().mockReturnValue(false),
isOllamaModel: vi.fn().mockReturnValue(false),
isLmstudioModel: vi.fn().mockReturnValue(false),
getFirstKnownLocalModelId: vi.fn().mockReturnValue(null),
} as any;
const mediaEngine = {
getMedia: vi.fn(),
updateMedia: vi.fn(),
upsertMediaTranslation: vi.fn(),
} as any;
const postEngine = {
getPost: vi.fn(),
updatePost: vi.fn(),
upsertPostTranslation: vi.fn(),
} as any;
return { chatEngine, providers, mediaEngine, postEngine };
}
describe('translatePost: auto-detect language when not set', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('detects and persists language before translating when post has no language', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Mein Beitrag',
excerpt: 'Zusammenfassung',
content: 'Dies ist ein deutscher Blogbeitrag über verschiedene Themen.',
language: undefined, // No language set
status: 'draft',
});
deps.postEngine.updatePost.mockResolvedValue({
id: 'post-1',
language: 'de',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'My Post',
excerpt: 'Summary',
content: 'This is a German blog post about various topics.',
});
// First call: language detection response
// Then: metadata translation response
// Then: content translation response
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
.mockResolvedValueOnce({ text: '{"title":"My Post","excerpt":"Summary"}' } as any) // metadata translation
.mockResolvedValueOnce({ text: 'This is a German blog post about various topics.' } as any); // content translation
const result = await tasks.translatePost('post-1', 'en');
expect(result.success).toBe(true);
// Language detection should have been called (first generateText call)
expect(mockGenerateText).toHaveBeenCalledTimes(3);
// Language should have been persisted
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'de' });
// Translation prompts should use detected language 'de', not fallback 'en'
const metadataCall = mockGenerateText.mock.calls[1][0];
expect((metadataCall as any).system).toContain('de');
});
it('skips detection when post has explicit language set', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'This is an English blog post.',
language: 'en', // Explicitly set
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Article',
excerpt: 'Résumé',
content: 'Ceci est un article de blog en anglais.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any) // metadata translation
.mockResolvedValueOnce({ text: 'Ceci est un article de blog en anglais.' } as any); // content translation
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
// Only 2 calls: metadata + content translation, NO language detection
expect(mockGenerateText).toHaveBeenCalledTimes(2);
// updatePost should NOT have been called for language
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
});
it('proceeds with fallback when language detection fails', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
excerpt: 'Summary',
content: 'Some content here.',
language: undefined,
status: 'draft',
});
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'fr',
title: 'Mon Article',
excerpt: 'Résumé',
content: 'Du contenu ici.',
});
mockGenerateText
.mockResolvedValueOnce({ text: 'garbage response' } as any) // language detection fails
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any)
.mockResolvedValueOnce({ text: 'Du contenu ici.' } as any);
const result = await tasks.translatePost('post-1', 'fr');
expect(result.success).toBe(true);
// updatePost should NOT have been called since detection failed
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
// Should still proceed with translation (3 calls total: detection + 2 translation)
expect(mockGenerateText).toHaveBeenCalledTimes(3);
});
it('uses detected language in translation prompts', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Mon Article',
excerpt: '',
content: 'Ceci est un article français.',
language: undefined,
status: 'draft',
});
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'fr' });
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'en',
title: 'My Article',
excerpt: '',
content: 'This is a French article.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "fr"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"My Article","excerpt":""}' } as any)
.mockResolvedValueOnce({ text: 'This is a French article.' } as any);
await tasks.translatePost('post-1', 'en');
// The translation prompt should use 'fr' as source language
const metadataSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
const contentSystemPrompt = mockGenerateText.mock.calls[2][0].system as string;
expect(metadataSystemPrompt).toContain('from fr to en');
expect(contentSystemPrompt).toContain('from fr to en');
});
it('treats null language the same as undefined (no language)', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test Post',
excerpt: '',
content: 'Some content.',
language: null, // null, not undefined
status: 'draft',
});
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'en' });
deps.postEngine.upsertPostTranslation.mockResolvedValue({
id: 'translation-1',
translationFor: 'post-1',
language: 'de',
title: 'Test',
excerpt: '',
content: 'Inhalt.',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"Test","excerpt":""}' } as any)
.mockResolvedValueOnce({ text: 'Inhalt.' } as any);
await tasks.translatePost('post-1', 'de');
// Should have called detection since language is null
expect(mockGenerateText).toHaveBeenCalledTimes(3);
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'en' });
});
});
describe('translateMediaMetadata: auto-detect language when not set', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('detects and persists language before translating when media has no language', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Sonnenuntergang am Meer',
alt: 'Ein wunderschöner Sonnenuntergang',
caption: 'Aufgenommen im Sommer',
language: undefined,
});
deps.mediaEngine.updateMedia.mockResolvedValue({
id: 'media-1',
language: 'de',
});
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'en',
title: 'Sunset at Sea',
alt: 'A beautiful sunset',
caption: 'Taken in summer',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
.mockResolvedValueOnce({ text: '{"title":"Sunset at Sea","alt":"A beautiful sunset","caption":"Taken in summer"}' } as any); // translation
const result = await tasks.translateMediaMetadata('media-1', 'en');
expect(result.success).toBe(true);
expect(mockGenerateText).toHaveBeenCalledTimes(2);
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'de' });
// Translation prompt should use detected language 'de'
const translationCall = mockGenerateText.mock.calls[1][0];
expect((translationCall as any).system).toContain('de');
});
it('skips detection when media has explicit language set', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Sunset at Sea',
alt: 'A beautiful sunset',
caption: 'Taken in summer',
language: 'en',
});
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'de',
title: 'Sonnenuntergang',
alt: 'Schön',
caption: 'Im Sommer',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"title":"Sonnenuntergang","alt":"Schön","caption":"Im Sommer"}' } as any);
const result = await tasks.translateMediaMetadata('media-1', 'de');
expect(result.success).toBe(true);
expect(mockGenerateText).toHaveBeenCalledTimes(1);
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
});
it('proceeds with fallback when media language detection fails', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Test Image',
alt: 'Alt text',
caption: 'Caption',
language: undefined,
});
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'de',
title: 'Testbild',
alt: 'Alt',
caption: 'Beschriftung',
});
mockGenerateText
.mockResolvedValueOnce({ text: 'garbage' } as any) // detection fails
.mockResolvedValueOnce({ text: '{"title":"Testbild","alt":"Alt","caption":"Beschriftung"}' } as any);
const result = await tasks.translateMediaMetadata('media-1', 'de');
expect(result.success).toBe(true);
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
expect(mockGenerateText).toHaveBeenCalledTimes(2);
});
it('uses detected language in media translation prompts', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Tramonto sul mare',
alt: 'Un bel tramonto',
caption: 'Fotografato in estate',
language: undefined,
});
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'it' });
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'en',
title: 'Sunset',
alt: 'Beautiful',
caption: 'Summer',
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "it"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"Sunset","alt":"Beautiful","caption":"Summer"}' } as any);
await tasks.translateMediaMetadata('media-1', 'en');
const translationSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
expect(translationSystemPrompt).toContain('from it to en');
});
it('treats null language the same as undefined (no language)', async () => {
deps.mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
title: 'Test',
alt: 'Alt',
caption: null,
language: null,
});
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'en' });
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
id: 'mt-1',
translationFor: 'media-1',
language: 'de',
title: 'Test',
alt: 'Alt',
caption: null,
});
mockGenerateText
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
.mockResolvedValueOnce({ text: '{"title":"Test","alt":"Alt","caption":null}' } as any);
await tasks.translateMediaMetadata('media-1', 'de');
expect(mockGenerateText).toHaveBeenCalledTimes(2);
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'en' });
});
});

View File

@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest';
import { retryWithBackoff } from '../../../src/main/engine/ai/retry';
describe('retryWithBackoff', () => {
it('returns immediately on success (no retries)', async () => {
const fn = vi.fn(async () => ({ success: true, value: 42 }));
const result = await retryWithBackoff(fn);
expect(result).toEqual({ success: true, value: 42 });
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries up to maxRetries times with exponential delays on failure', async () => {
vi.useFakeTimers();
const fn = vi.fn(async () => ({ success: false, error: 'rate limited' }));
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
// Initial call happens immediately
await vi.advanceTimersByTimeAsync(0);
expect(fn).toHaveBeenCalledTimes(1);
// Retry 1 after 5s
await vi.advanceTimersByTimeAsync(5000);
expect(fn).toHaveBeenCalledTimes(2);
// Retry 2 after 10s
await vi.advanceTimersByTimeAsync(10000);
expect(fn).toHaveBeenCalledTimes(3);
// Retry 3 after 20s
await vi.advanceTimersByTimeAsync(20000);
expect(fn).toHaveBeenCalledTimes(4);
const result = await promise;
expect(result).toEqual({ success: false, error: 'rate limited' });
vi.useRealTimers();
});
it('stops retrying once the function succeeds', async () => {
vi.useFakeTimers();
const fn = vi.fn()
.mockResolvedValueOnce({ success: false, error: 'fail' })
.mockResolvedValueOnce({ success: true, value: 'ok' });
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
// Initial call fails
await vi.advanceTimersByTimeAsync(0);
expect(fn).toHaveBeenCalledTimes(1);
// Retry 1 after 5s — succeeds
await vi.advanceTimersByTimeAsync(5000);
expect(fn).toHaveBeenCalledTimes(2);
const result = await promise;
expect(result).toEqual({ success: true, value: 'ok' });
// Should not retry further
expect(fn).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('uses default 3 retries with 5s base delay', async () => {
vi.useFakeTimers();
const fn = vi.fn(async () => ({ success: false }));
const promise = retryWithBackoff(fn);
// Initial + 3 retries = 4 total calls
await vi.advanceTimersByTimeAsync(0); // initial
await vi.advanceTimersByTimeAsync(5000); // retry 1 (5s)
await vi.advanceTimersByTimeAsync(10000); // retry 2 (10s)
await vi.advanceTimersByTimeAsync(20000); // retry 3 (20s)
await promise;
expect(fn).toHaveBeenCalledTimes(4);
vi.useRealTimers();
});
it('applies exponential doubling: 5s, 10s, 20s', async () => {
vi.useFakeTimers();
const delays: number[] = [];
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const fn = vi.fn(async () => ({ success: false }));
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(10000);
await vi.advanceTimersByTimeAsync(20000);
await promise;
// Extract the delay values passed to setTimeout for our retries
const timeoutCalls = setTimeoutSpy.mock.calls
.filter(([, ms]) => typeof ms === 'number' && ms >= 5000)
.map(([, ms]) => ms);
expect(timeoutCalls).toContain(5000);
expect(timeoutCalls).toContain(10000);
expect(timeoutCalls).toContain(20000);
setTimeoutSpy.mockRestore();
vi.useRealTimers();
});
});

View File

@@ -24,4 +24,12 @@ describe('main/shared locale completeness', () => {
expect(localeKeys, `Locale ${locale} is missing or has extra keys`).toEqual(englishKeys);
}
});
it('includes a native-menu label for validate translations', () => {
expect((en as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((de as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((fr as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((itLocale as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
expect((es as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
});
});

View File

@@ -3,6 +3,7 @@ import {
resolveSupportedRenderLanguage,
resolveRenderLanguageFromProjectPreferences,
translateRender,
getRenderTranslations,
} from '../../src/main/shared/i18n';
describe('render i18n', () => {
@@ -24,4 +25,17 @@ describe('render i18n', () => {
expect(translateRender('es', 'render.pagination.older')).toBe('más antiguo');
expect(translateRender('fr', 'missing.key')).toBe('missing.key');
});
it('returns full translation map for a language', () => {
const translations = getRenderTranslations('de');
expect(translations).toBeDefined();
expect(typeof translations).toBe('object');
expect(translations['render.pagination.newer']).toBe('neuer');
expect(translations['render.archive']).toBe('Archiv');
});
it('falls back to English for unsupported languages', () => {
const translations = getRenderTranslations('en');
expect(translations['render.archive']).toBe('Archive');
});
});

View File

@@ -12,6 +12,9 @@ const mockPostEngine: Record<string, ReturnType<typeof vi.fn>> = {
updatePost: vi.fn().mockResolvedValue(null),
deletePost: vi.fn().mockResolvedValue(true),
publishPost: vi.fn().mockResolvedValue(null),
publishPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'fr' }),
getPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'en', content: 'hello' }),
getPostTranslations: vi.fn().mockResolvedValue([{ id: 'tr1', language: 'en' }]),
discardChanges: vi.fn().mockResolvedValue(null),
hasPublishedVersion: vi.fn().mockResolvedValue(false),
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
@@ -431,6 +434,21 @@ describe('invokeMainProcessPythonApi', () => {
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('test', undefined);
});
it('routes posts.publishTranslation to postEngine.publishPostTranslation', async () => {
await invokeMainProcessPythonApi('posts.publishTranslation', { postId: 'p1', language: 'fr' });
expect(mockPostEngine.publishPostTranslation).toHaveBeenCalledWith('p1', 'fr');
});
it('routes posts.getTranslation to postEngine.getPostTranslation', async () => {
await invokeMainProcessPythonApi('posts.getTranslation', { postId: 'p1', language: 'en' });
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
});
it('routes posts.getTranslations to postEngine.getPostTranslations', async () => {
await invokeMainProcessPythonApi('posts.getTranslations', { postId: 'p1' });
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
});
it('handles null args gracefully (normalizes to empty record)', async () => {
await expect(
invokeMainProcessPythonApi('posts.get', null as unknown as Record<string, unknown>),

View File

@@ -1221,6 +1221,7 @@ describe('main bootstrap preview behavior', () => {
createPost,
setProjectContext: vi.fn(),
setSearchLanguage: vi.fn(),
setMainLanguage: vi.fn(),
}; }),
}));

View File

@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest';
import type { PostData } from '../../src/main/engine/PostEngine';
import {
applyLanguagePrefixToHtml,
PageRenderer,
type HtmlRewriteContext,
} from '../../src/main/engine/PageRenderer';
import {
buildSitemapAndFeeds,
buildMultiLanguageSitemap,
type GenerationPostIndexLike,
} from '../../src/main/engine/GenerationSitemapFeedService';
function makePost(overrides: Partial<PostData> = {}): PostData {
const createdAt = overrides.createdAt ?? new Date('2025-03-08T10:00:00.000Z');
return {
id: overrides.id ?? 'post-1',
projectId: overrides.projectId ?? 'default',
title: overrides.title ?? 'Test Post',
slug: overrides.slug ?? 'test-post',
excerpt: overrides.excerpt,
content: overrides.content ?? '# Test\n\nBody text',
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language ?? 'en',
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
publishedAt: overrides.publishedAt,
tags: overrides.tags ?? [],
categories: overrides.categories ?? [],
};
}
function buildIndex(posts: PostData[]): GenerationPostIndexLike {
const postsByCategory = new Map<string, PostData[]>();
const postsByTag = new Map<string, PostData[]>();
const postsByYear = new Map<number, PostData[]>();
const postsByYearMonth = new Map<string, PostData[]>();
const postsByYearMonthDay = new Map<string, PostData[]>();
for (const post of posts) {
for (const category of (post.categories ?? [])) {
postsByCategory.set(category, [...(postsByCategory.get(category) ?? []), post]);
}
for (const tag of (post.tags ?? [])) {
postsByTag.set(tag, [...(postsByTag.get(tag) ?? []), post]);
}
const year = post.createdAt.getFullYear();
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
const day = String(post.createdAt.getDate()).padStart(2, '0');
postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]);
postsByYearMonth.set(`${year}/${month}`, [...(postsByYearMonth.get(`${year}/${month}`) ?? []), post]);
postsByYearMonthDay.set(`${year}/${month}/${day}`, [...(postsByYearMonthDay.get(`${year}/${month}/${day}`) ?? []), post]);
}
return { postsByCategory, postsByTag, postsByYear, postsByYearMonth, postsByYearMonthDay };
}
describe('applyLanguagePrefixToHtml', () => {
it('prefixes internal hrefs with language prefix', () => {
const html = '<a href="/2025/03/08/my-post/">Post</a>';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
});
it('does not prefix media or asset paths', () => {
const html = '<img src="/media/2025/03/photo.jpg" /><link href="/assets/bds.css" />';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe(html);
});
it('does not double-prefix already prefixed hrefs', () => {
const html = '<a href="/de/2025/03/08/my-post/">Post</a>';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
});
it('prefixes root href', () => {
const html = '<a href="/">Home</a>';
const result = applyLanguagePrefixToHtml(html, '/fr');
expect(result).toBe('<a href="/fr/">Home</a>');
});
it('prefixes category and tag hrefs', () => {
const html = '<a href="/category/tech/">Tech</a><a href="/tag/js/">JS</a>';
const result = applyLanguagePrefixToHtml(html, '/es');
expect(result).toBe('<a href="/es/category/tech/">Tech</a><a href="/es/tag/js/">JS</a>');
});
it('returns html unchanged when prefix is empty', () => {
const html = '<a href="/some/path">Link</a>';
const result = applyLanguagePrefixToHtml(html, '');
expect(result).toBe(html);
});
it('prefixes pagination hrefs', () => {
const html = '<a href="/page/2">Next</a>';
const result = applyLanguagePrefixToHtml(html, '/de');
expect(result).toBe('<a href="/de/page/2">Next</a>');
});
it('handles both single and double quotes', () => {
const html = `<a href='/tag/foo/'>Foo</a><a href="/tag/bar/">Bar</a>`;
const result = applyLanguagePrefixToHtml(html, '/it');
expect(result).toBe(`<a href='/it/tag/foo/'>Foo</a><a href="/it/tag/bar/">Bar</a>`);
});
});
describe('data-language-prefix on html tag', () => {
it('renders data-language-prefix on post-list html tag', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'fr',
language_prefix: '/fr',
},
);
expect(html).toContain('data-language-prefix="/fr"');
});
it('renders empty data-language-prefix for main language', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'en',
language_prefix: '',
},
);
expect(html).toContain('data-language-prefix=""');
});
it('renders data-language-prefix on single-post html tag', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderSinglePost(
makePost({ content: 'Hello world' }),
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
page_title: 'Blog',
language: 'de',
language_prefix: '/de',
},
);
expect(html).toContain('data-language-prefix="/de"');
});
});
describe('Feed language filtering', () => {
it('includes feedLanguage element in RSS when specified', () => {
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Blog',
maxPostsPerPage: 10,
publishedPosts: posts,
publishedListPosts: posts,
postIndex: buildIndex(posts),
includeFeeds: true,
feedLanguage: 'de',
});
expect(result.rssXml).toContain('<language>de</language>');
});
it('includes xml:lang in Atom feed when feedLanguage is specified', () => {
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Blog',
maxPostsPerPage: 10,
publishedPosts: posts,
publishedListPosts: posts,
postIndex: buildIndex(posts),
includeFeeds: true,
feedLanguage: 'fr',
});
expect(result.atomXml).toContain('xml:lang="fr"');
});
it('omits language elements when feedLanguage is not specified', () => {
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Blog',
maxPostsPerPage: 10,
publishedPosts: posts,
publishedListPosts: posts,
postIndex: buildIndex(posts),
includeFeeds: true,
});
expect(result.rssXml).not.toContain('<language>');
expect(result.atomXml).not.toMatch(/<feed[^>]+xml:lang=/);
});
});
describe('buildMultiLanguageSitemap', () => {
it('generates hreflang links for translatable posts in all languages', () => {
const post = makePost({ id: '1', slug: 'hello', title: 'Hello' });
const postIndex = buildIndex([post]);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'de'],
translatablePosts: [post],
doNotTranslatePosts: [],
publishedListPosts: [post],
maxPostsPerPage: 10,
postIndex,
});
expect(sitemap).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
expect(sitemap).toContain('hreflang="en"');
expect(sitemap).toContain('hreflang="de"');
expect(sitemap).toContain('hreflang="x-default"');
// Main language post URL is unprefixed
expect(sitemap).toContain('href="https://example.com/2025/03/08/hello/"');
// Alternative language post URL is prefixed
expect(sitemap).toContain('href="https://example.com/de/2025/03/08/hello/"');
});
it('generates hreflang links only for main language for doNotTranslate posts', () => {
const dntPost = makePost({ id: '2', slug: 'private-note', title: 'Private' });
(dntPost as PostData & { doNotTranslate?: boolean }).doNotTranslate = true;
const postIndex = buildIndex([dntPost]);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'de', 'fr'],
translatablePosts: [],
doNotTranslatePosts: [dntPost],
publishedListPosts: [dntPost],
maxPostsPerPage: 10,
postIndex,
});
// The doNotTranslate post URL entry should exist
expect(sitemap).toContain('https://example.com/2025/03/08/private-note/');
// But it should NOT have de or fr hreflang links for this specific post
const postUrlBlock = sitemap.split('<url>').find((block) => block.includes('private-note'));
expect(postUrlBlock).toBeDefined();
expect(postUrlBlock).toContain('hreflang="en"');
expect(postUrlBlock).not.toContain('hreflang="de"');
expect(postUrlBlock).not.toContain('hreflang="fr"');
});
it('includes root page and pagination in all languages', () => {
const posts = Array.from({ length: 15 }, (_, i) =>
makePost({
id: `p-${i}`,
slug: `post-${i}`,
createdAt: new Date(`2025-03-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
}),
);
const postIndex = buildIndex(posts);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'de'],
translatablePosts: posts,
doNotTranslatePosts: [],
publishedListPosts: posts,
maxPostsPerPage: 10,
postIndex,
});
// Root page has both languages
expect(sitemap).toContain('https://example.com/');
expect(sitemap).toContain('https://example.com/de/');
// Pagination page 2
expect(sitemap).toContain('https://example.com/page/2');
expect(sitemap).toContain('https://example.com/de/page/2');
});
it('includes archive, category, and tag URLs in all languages', () => {
const post = makePost({
id: '1', slug: 'tagged', tags: ['javascript'], categories: ['tutorial'],
});
const postIndex = buildIndex([post]);
const sitemap = buildMultiLanguageSitemap({
baseUrl: 'https://example.com',
mainLanguage: 'en',
allLanguages: ['en', 'fr'],
translatablePosts: [post],
doNotTranslatePosts: [],
publishedListPosts: [post],
maxPostsPerPage: 10,
postIndex,
});
expect(sitemap).toContain('https://example.com/category/tutorial');
expect(sitemap).toContain('https://example.com/fr/category/tutorial');
expect(sitemap).toContain('https://example.com/tag/javascript');
expect(sitemap).toContain('https://example.com/fr/tag/javascript');
expect(sitemap).toContain('https://example.com/2025/');
});
});
describe('Language switcher in templates', () => {
it('renders language switcher badges when blog_languages has multiple entries', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map([['test-post', '/2025/03/08/test-post']]),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: true,
routeKind: 'date',
archiveContext: { kind: 'root' },
basePathname: '/',
page_title: 'Blog',
language: 'en',
blog_languages: [
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true },
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: false },
],
current_language: 'en',
language_prefix: '',
},
);
expect(html).toContain('class="language-switcher"');
expect(html).toContain('class="language-switcher-badge language-switcher-badge-current"');
expect(html).toContain('🇬🇧');
expect(html).toContain('href="/de"');
expect(html).toContain('🇩🇪');
});
it('does not render language switcher when blog_languages has one or zero entries', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'en',
blog_languages: [{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true }],
current_language: 'en',
language_prefix: '',
},
);
expect(html).not.toContain('class="language-switcher"');
});
it('renders language switcher in single post template', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderSinglePost(
makePost({ content: 'Hello world' }),
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
page_title: 'Blog',
language: 'en',
blog_languages: [
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: false },
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: true },
],
current_language: 'de',
language_prefix: '/de',
},
);
expect(html).toContain('class="language-switcher"');
expect(html).toContain('aria-current="true"');
expect(html).toContain('🇩🇪');
});
});
describe('Per-language feed links in head', () => {
it('renders language-prefixed feed links in head partial', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'de',
language_prefix: '/de',
},
);
expect(html).toContain('href="/de/rss.xml"');
expect(html).toContain('href="/de/atom.xml"');
});
it('renders unprefixed feed links when no language prefix', async () => {
const renderer = new PageRenderer(
{ getAllMedia: async () => [] },
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
);
const html = await renderer.renderPostList(
[makePost()],
{
canonicalPostPathBySlug: new Map(),
canonicalMediaPathBySourcePath: new Map(),
},
{
archiveGrouping: false,
routeKind: 'date',
basePathname: '/',
page_title: 'Blog',
language: 'en',
},
);
expect(html).toContain('href="/rss.xml"');
expect(html).toContain('href="/atom.xml"');
});
});

Some files were not shown because too many files have changed in this diff Show More