Triptych
Single-post multilingual WordPress plugin: one canonical post, one language field per language, an in-canvas language pill bar in the Block Editor, per-block translate via any OpenAI-compatible endpoint (DeepSeek by default), and a translation-coverage screen. MIT.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/rennerdo30/wp-triptych/archive/refs/heads/main.zipReadme
Triptych
One canonical post. N languages. In-canvas tab bar. Per-block AI translate. No per-language post twins.
Triptych is a standalone WordPress plugin that takes a different stance from Polylang and WPML on multilingual content: instead of duplicating posts per language, it stores every language value as a field on a single canonical post. Switching languages while editing never takes you to a different post — you click a pill at the top of the canvas.
Why
Polylang and WPML use a per-post-per-language model: editing the Japanese version of an article means navigating to a different post entity. That makes sense for SEO scaffolding, but it's painful to author with — translating a hero block, a CTA, or a sidebar means hopping between posts and constantly losing your place. ACF + Polylang's free tier compounds the issue: ACF field values silently belong to the post they live on, not the translation set.
Triptych takes the opposite stance: one canonical post entity, multiple language fields.
| Polylang / WPML | Triptych | |
|---|---|---|
| Posts per article | N (one per language) | 1 |
| Editing UI | Switch posts | Click a pill at the top of the canvas |
| Storage | Separate wp_posts rows |
_triptych_{field}_{lang} postmeta |
| URL routing | /ja/foo-ja-slug/ |
/ja/foo/ (same canonical post) |
| Field translation | One copy of ACF per twin | One copy, language-keyed |
| AI translation | Bring your own | Built in, per-block, OpenAI-compatible |
This is opinionated. If you need fully independent slugs / categories / SEO meta per language, Polylang Pro or WPML are still the right tools. If you want a clean per-field editor and a tiny dependency footprint, Triptych is built for you.
Quick start
# Inside wp-content/plugins/
git clone https://github.com/rennerdo30/wp-triptych.git
Requires WordPress 6.5+ and PHP 8.1+. No Composer install is needed to run the plugin — dependencies are dev-only (PHPUnit).
Activate the plugin in Plugins → Installed Plugins, then open the top-level Triptych menu in wp-admin to:
- Configure your language list (
triptych_languages, defaultzh:中文,ja:日本語,en:English). - Pick a default language (
triptych_default_language, defaulten). - (Optional) Point the AI translator at DeepSeek / OpenAI / Anthropic / Ollama / etc. via endpoint URL, API key, and model name.
Edit any post — a pill bar sits across the top of the canvas, one pill per configured language. Click a pill to swap title + content + every registered field to that language.
Configuration options
Every setting is a plain WP option, so it can also be set with WP-CLI or in a bootstrap plugin:
| Option | Default | Purpose |
|---|---|---|
triptych_languages |
zh:中文,ja:日本語,en:English |
Comma-separated slug:Label pairs. Any number of languages. |
triptych_default_language |
en |
Source language for translation and fallback for missing values. |
triptych_endpoint |
https://api.deepseek.com/v1 |
OpenAI-compatible chat-completions base URL. |
triptych_api_key |
(empty) | API key for that endpoint. |
triptych_model |
deepseek-v4-pro |
Model name sent with each request. |
In-canvas editing (v0.3.x)
The Block Editor is the primary surface. Triptych enqueues an admin-editor.js bundle that:
- Mounts a pill bar at the top of the editor canvas with one button per configured language. The active pill is filled; non-default pills carry a status dot — green for translated, amber for legacy postmeta, hollow for empty.
- Adds a Translate toolbar button to standard block types (paragraph, heading, list, list-item, quote, code, preformatted, verse). Click it on a block in a non-default language and Triptych translates that single block's RichText payload from the source language and replaces the block content in place.
- Tracks per-block source-drift — when you translate, Triptych snapshots the source block's hash (stored under
_triptych_<field>_<lang>_src_hashes). On reload, blocks whose source has changed since translation are flagged stale so editors know which blocks to retranslate. - Falls back to a classic-editor metabox automatically on post types that opt out of the Block Editor.
The classic metabox stays registered as a fallback and self-disables when use_block_editor_for_post_type() is true.
Admin surfaces
Beyond the editor canvas, Triptych adds three places to see and drive translation state:
- Triptych → Translations — a coverage table of every publishable post in any post type that has at least one Triptych field, with one status pill per language. Filter by post type, language, or title; hit Translate missing on a row to fill empty
post_title+post_contentfor all non-default languages; or bulk-translate the whole filtered set into one language with live progress. Cells for non-default languages open a popover with Edit / Re-translate / Delete. - A "Languages" column on
edit.phplist tables (added just before Date), showing a filled pill for the default language, green for languages with content, and a grey outline for missing ones. - An admin-bar language switcher, Polylang-style. The choice is stored per user in the
triptych_admin_languser meta, so it survives page loads without leaking between users; the Block Editor opens on that language and list-table titles render in it.
Field registration API
By default, Triptych makes post_title and post_content multilingual. Theme authors register additional fields:
add_action('plugins_loaded', function () {
triptych_register_multilingual_field('post_title');
triptych_register_multilingual_field('post_content');
triptych_register_multilingual_field('event_subtitle', [
'type' => 'text',
'post_types' => ['event'],
'label' => 'Subtitle',
]);
triptych_register_multilingual_field('event_description', [
'type' => 'textarea',
'post_types' => ['event'],
'label' => 'Description',
]);
});
Read values in templates:
echo esc_html(triptych_get_field(get_the_ID(), 'event_subtitle'));
// → reads _triptych_event_subtitle_<current_url_lang>, falls back to default lang.
The the_title() and the_content() filters are wired automatically; you only need triptych_get_field() for custom fields.
Public procedural API
triptych_register_multilingual_field(string $field, array $args = []): void
triptych_get_field(int $post_id, string $field, ?string $lang = null): string
triptych_current_language(): string
triptych_languages(): array // ['zh' => '中文', 'ja' => '日本語', 'en' => 'English']
triptych_default_language(): string
Storage
Storage convention: _triptych_{field}_{lang} postmeta as a plain scalar (string) — no envelope, no JSON, no serialization. This keeps every language value addressable from any tool that already speaks postmeta (WP-CLI, WP REST API, MySQL backups, search/replace).
Per-block source-drift hashes are stored alongside under _triptych_{field}_{lang}_src_hashes as a flat array of hash strings, one per top-level block at translate time.
Fields::get() resolves through this fallback chain so themes migrating off other systems surface existing data without a forced bulk rewrite:
_triptych_<field>_<lang>postmeta (canonical Triptych storage).- Legacy flat per-language postmeta (
<field>_cn,<field>_jp,<field>_en) and ACF serialised group arrays. - Same chain in the default language, for posts whose translation is missing.
- Native post column (
post_title/post_content/post_excerpt).
REST endpoints
| Route | Method | Auth | Purpose |
|---|---|---|---|
/wp-json/triptych/v1/post/<id> |
GET |
edit_post on <id> |
Snapshot of every registered field × language for the post, plus per-field source-hash arrays. |
/wp-json/triptych/v1/save |
POST |
edit_post on post_id |
Write one {post_id, field, lang, value, source_hashes?} tuple. Pass an empty source_hashes array to clear the drift snapshot. |
/wp-json/triptych/v1/translate |
POST |
edit_posts |
Translate {from, to, text, field?} via the configured endpoint. |
AI auto-translate
Triptych speaks the OpenAI Chat Completions schema, the lingua franca of LLM endpoints. The default endpoint is DeepSeek (https://api.deepseek.com/v1, model deepseek-v4-pro) because its pricing is sane for per-block translation calls. Swap to anything compatible:
| Provider | Endpoint URL | Example model |
|---|---|---|
| DeepSeek (default) | https://api.deepseek.com/v1 |
deepseek-v4-pro |
| OpenAI | https://api.openai.com/v1 |
gpt-4o-mini |
| Anthropic (Claude) | https://api.anthropic.com/v1 |
claude-haiku-4.5 |
| Mistral | https://api.mistral.ai/v1 |
mistral-small-latest |
| Together | https://api.together.xyz/v1 |
meta-llama/Llama-3.3-70B |
| Groq | https://api.groq.com/openai/v1 |
llama-3.3-70b-versatile |
| Ollama (local) | http://localhost:11434/v1 |
llama3.2 |
| vLLM / llama.cpp | your own | your own |
Translation calls run per block from the toolbar button. The classic-editor metabox keeps a per-pane AI translate button that fills the entire field at once, and Triptych → Translations drives the same translator across many posts.
URL routing
Triptych registers a request-time Router that strips the language prefix from REQUEST_URI before WordPress parses the request, so the same WP_Query resolves the same post regardless of language. Outbound home_url() and permalink filters prefix the language back on. The TitleFilter and ContentFilter then read the language-keyed postmeta at render time. HreflangEmitter emits <link rel="alternate" hreflang> tags for every configured language.
┌──────────────────────────────────────────────┐
│ ONE canonical wp_posts row (id = 42) │
│ ─ post_title: "Untitled" (legacy) │
│ ─ post_content: "" (legacy) │
│ │
│ postmeta: │
│ _triptych_post_title_zh → "三联画" │
│ _triptych_post_title_ja → "三連画" │
│ _triptych_post_title_en → "Triptych" │
│ _triptych_post_content_zh → "<p>…</p>" │
│ _triptych_post_content_ja → "<p>…</p>" │
│ _triptych_post_content_en → "<p>…</p>" │
│ _triptych_post_content_ja_src_hashes → │
│ ["a1b2…", "c3d4…", "e5f6…"] │
└────────────────────┬─────────────────────────┘
│
inbound URL routing │ outbound rendering
┌─────────────────┐ │ ┌────────────────────┐
/zh/triptych/ ──────►│ Router │◄──────────│ TitleFilter │
/ja/triptych/ ──────►│ strips │ │ ContentFilter │
/en/triptych/ ──────►│ /lang/ │ │ PermalinkFilter │
└────┬────┘ │ HreflangEmitter │
│ └────────────────────┘
▼
same WP query →
same post ID 42 →
fields swap by current language
Development
Plain PHP 8.1 with a small hand-rolled PSR-4 autoloader, vanilla JS for the editor bundle, and no build step. Composer is only needed for the test tooling:
composer install
composer test # phpunit
composer lint # php -l across src/
Known limitations
- One canonical slug. Per-language slugs are not supported.
/zh/foo/and/ja/foo/resolve the same post; the slug is whatever the canonical row has. Per-language slugs are on the roadmap as opt-in. - One canonical taxonomy term set. Categories and tags belong to the canonical post; they don't translate. Use a translatable taxonomy plugin or maintain term labels via
single_term_titlefilters in your theme. - One canonical SEO meta set. If you need per-language
description/ OpenGraph payloads, Triptych won't give them to you out of the box — wire your SEO plugin's filters throughtriptych_current_language(). - Block-level translate covers core RichText blocks. Nested blocks (groups, columns) translate their inner blocks individually. Custom blocks aren't auto-detected; their content survives the translate flow but you'll have to invoke translation from the field-level pane.
- Source-drift hashes are top-level only. The snapshot tracks each top-level block; reordering blocks in the source language without retranslating will report drift on every changed slot.
Roadmap
- [ ] Taxonomy term translation — categories, tags, and custom taxonomies.
- [ ] REST API per-language — accept
?triptych_lang=jaon standard/wp/v2/postsand swap fields server-side. - [x] Bulk translate — shipped on the Translations screen (per-row Translate missing plus a bulk run across the filtered set).
- [ ] Translation memory — cache common phrases to skip duplicate API calls.
- [ ] Per-language slugs — opt-in, since most sites won't need it.
- [ ] Polylang import tool — migrate post-twin sites onto Triptych in one shot.
License
MIT © 2026 Renner