npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@justanarthur/payload-plugin-translator

v3.4.3

Published

Translator plugin for Payload CMS — automatic localization via Google, OpenAI, LibreTranslate, or custom resolvers.

Readme

@justanarthur/payload-plugin-translator

Payload CMS plugin that keeps localized docs in sync. On every save of a listed collection or global, it schedules background jobs that translate each field from the source locale into every other declared locale, using a configurable resolver (Google Translate, OpenAI, LibreTranslate, or your own).

Editor UX: the publish and save buttons in the affected collections/globals are swapped for a custom variant that surfaces the job status. Locales without translations stay empty until the job finishes — the plugin never blocks the editor's save.

Install

pnpm add @justanarthur/payload-plugin-translator

Requires payload@^3.85.0 and react@^19. The plugin reads your config.localization.locales — it short-circuits (returns the config unchanged) when there's only one locale or no localization.

Quick start

// payload.config.ts
import { buildConfig } from 'payload'
import { translator, openAIResolver } from '@justanarthur/payload-plugin-translator'

export default buildConfig({
  plugins: [
    translator({
      collections: ['pages', 'posts'],
      globals: ['header', 'footer'],
      resolvers: [
        openAIResolver({ apiKey: process.env.OPENAI_API_KEY! })
      ]
    })
  ]
})

That's the whole integration. The first save of a Page doc schedules one translate job per non-source locale; the job runs against your resolver and writes each translated field back into the collection's _locales row.

Built-in resolvers

| Resolver | Import | Env / config | Notes | |--------------------|-------------------------------------------------|---------------------------------|------------------------------------------------------------------| | googleResolver | @justanarthur/payload-plugin-translator/resolvers/google | apiKey (Google Cloud Translation API) | Cheap, good for bulk text. Locale codes are remapped where useful (e.g. ua → uk). | | openAIResolver | @justanarthur/payload-plugin-translator/resolvers/openAI | apiKey, optional model/baseUrl/prompt/chunkLength | Best fidelity. Default model gpt-4o-mini. gpt-5.x uses max_completion_tokens + reasoning_effort: 'low' automatically. Slugs get a transliteration rule baked into the prompt. | | libreResolver | @justanarthur/payload-plugin-translator/resolvers/libreTranslate | apiKey, optional url/chunkLength | Self-host friendly. Same locale remap as Google. | | copyResolver | @justanarthur/payload-plugin-translator/resolvers/copy | none | Returns the source text verbatim. Useful as a fallback or for testing. |

You can supply multiple resolvers — they run in the order declared, and the first one to return success: true wins for the chunk.

Writing your own resolver

import type { TranslateResolver } from '@justanarthur/payload-plugin-translator/resolvers/types'

export const myResolver: TranslateResolver = {
  key: 'my-translator',
  resolve: async ({ localeFrom, localeTo, texts, req }) => {
    // call your translator of choice
    return { success: true, translatedTexts: ['...'] }
  }
}

texts is the flat string array extracted from the doc; you return the same-length array in translatedTexts. Returning { success: false } makes the job retry.

Options reference

| Option | Type | Required | Notes | |---------------|-----------------------|----------|------------------------------------------------------------------------| | collections | CollectionSlug[] | yes | Collections to auto-translate. | | globals | GlobalSlug[] | yes | Globals to auto-translate. | | resolvers | TranslateResolver[] | yes | Tried in order; first to succeed wins per chunk. | | autoTranslate | boolean | no | Default true. Set false to opt out of the auto-translate afterChange hook — useful when you drive translation manually via the translateOperation export. | | autoTranslateMode | 'missing' \| 'untranslated' \| 'all' | no | Default 'missing': a publish in the default locale fills empty fields in other locales and keeps existing translations and slugs. 'untranslated' also replaces fields that still hold the source copy. 'all' re-translates everything. | | languageDetection | boolean | no | Default true. The review view flags translated fields written in another locale's language, and untranslated mode re-translates them. | | review | boolean | no | Default true. Adds the /admin/translations review view, its endpoints and the hidden translation-status collection (needs a migration on postgres). | | disabled | boolean | no | Skip the plugin entirely (no overrides, no jobs). Useful in tests. | | _options.additionalTraverseRichText | function | no | Hook to extend rich-text traversal — see below. |

Rich-text traversal hook

The plugin walks Lexical richText fields itself, but custom Lexical nodes (e.g. blocks the host defines for landing pages) won't be visited. _options.additionalTraverseRichText lets you register a custom walker:

translator({
  // ...
  _options: {
    additionalTraverseRichText: ({ root, onText }) => {
      // call onText(siblingData, attribute?) for every leaf string you find
      // siblingData mutates the doc tree in-place
    }
  }
})

The hook receives onText which mutates the data tree, plus the current node as siblingData. It is called for every node without a text property, including block and inlineBlock nodes.

How it works under the hood

  • On plugin init: registers a translate Payload task and a translate workflow, swapped Publish/Save buttons per collection/global, and an admin-only endpoint at /api/translator/translate.
  • On afterChange (when autoTranslate: true): the plugin enqueues one workflow run per non-source locale. The job extracts translatable fields, asks each resolver in turn, writes the result back into the _locales row.
  • The plugin de-duplicates via AUTO_TRANSLATE_MARKER — re-running it on an already-attached hook is a no-op, so it's safe to wrap multiple plugins around the same collection.

Reviewing translations

With review on, Translations appears in the admin nav (/admin/translations):

  • Overview: one row per document (paged, newest first) or global, one column per target locale. Each cell shows the share of translatable fields that have a translation. 404 means the locale has no slug, ↻ means the source changed since the last translation, ✓ means someone reviewed that locale against the current source.

  • Detail (click a cell): every translatable field with the source and target text side by side, marked missing, same as source (likely never translated) or placeholders differ, plus the last auto-translate job and its error. Actions: Translate missing fields, Re-translate all, Mark reviewed.

  • Wrong language: translated fields are checked with eld (small n-gram set, loaded lazily on the server, limited to the configured locales). A field is flagged, shown as ⚑ in the overview and xx detected in the detail, only when it has at least 40 characters and 5 words after dropping emails, urls, numbers and slashed loanwords, is not a list of short lines, and the detected language leads the expected one by a margin (wider for close pairs such as cs/sk and es/pt, and for text under 80 characters). Flagged fields count against coverage, so bulk and per-document fixes re-translate them.

  • Bulk fix: Translate untranslated queues one translate workflow per incomplete document, for all locales or one, in untranslated mode (empty fields and fields still holding the source copy; existing translations and slugs stay). Locales a pending job already covers are skipped. The overview shows queued, running and failed jobs, and Run a batch now processes a few without waiting for the job runner. Needs autoTranslate on, since that registers the jobs.

The view only lists collections and globals the signed-in user can read, and the endpoints behind the actions require a user.

Manual translation

Two escape hatches when you don't want the auto-hook:

// the operation (Payload `operation` you can call from custom endpoints)
import { translateOperation } from '@justanarthur/payload-plugin-translator'

// the job factories (advanced — you usually don't need these directly)
import { createTranslateTask, createTranslateWorkflow } from '@justanarthur/payload-plugin-translator/jobs'

For most hosts the auto-hook + resolver list is enough.

Licence

MIT