WP Manifestindependent plugin directory
manifest / i18n / wp-triptych

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.

by renner.dev · github.com/rennerdo30/wp-triptych · website

0stars
0forks

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.zip

Readme

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:

  1. Configure your language list (triptych_languages, default zh:中文,ja:日本語,en:English).
  2. Pick a default language (triptych_default_language, default en).
  3. (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_content for 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.php list 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_lang user 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:

  1. _triptych_<field>_<lang> postmeta (canonical Triptych storage).
  2. Legacy flat per-language postmeta (<field>_cn, <field>_jp, <field>_en) and ACF serialised group arrays.
  3. Same chain in the default language, for posts whose translation is missing.
  4. 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_title filters 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 through triptych_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=ja on standard /wp/v2/posts and 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

Read the full README on GitHub →