ForgeWP

Extract, Translate, and Switch Languages

Keep your translation catalog current with the ForgeWP CLI, configure translation providers like DeepL and LibreTranslate, build accessible language switchers, and handle multilingual forms.

Once your components are instrumented with useWpI18n() and __(), you need a reliable way to collect those strings, populate translated text across all configured locales, and allow visitors to switch between languages in your theme UI.

ForgeWP provides dedicated CLI commands and runtime hooks to automate this entire lifecycle:

  • pnpm forgewp i18n:extract: Scans your React codebase, collects all translation keys, and synchronizes your cms/translations.ts catalog without overwriting existing translations.
  • pnpm forgewp i18n:translate: Connects to automated translation providers (DeepL, Google Translate, LibreTranslate, or local/manual) to translate untranslated keys at build time.
  • useWpLanguage(): Provides active language state, language-specific URLs, and navigation helpers to build accessible language switchers.
Flowchart
Rendering diagram...

Extract Translation Keys from Source Code

As you build and update React components, new translatable strings are added to JSX templates. Instead of manually copying strings into your translation file, run the ForgeWP extraction tool:

Terminal
pnpm forgewp i18n:extract

How the Extractor Works

When you run i18n:extract, the CLI performs the following steps:

  1. Scans Source Code: Recursively inspects all .ts, .tsx, .js, and .jsx files inside the src/ directory.
  2. Collects __() Invocations: Uses AST pattern matching to extract string arguments passed to __('...') and __("..."), automatically resolving escaped quotes.
  3. Reads Configuration: Loads wp.config.ts to identify the defaultLocale and the full list of locales.
  4. Merges Without Overwriting:
    • For the default locale (e.g. 'en'), new keys are initialized with the key itself as the value.
    • For secondary locales (e.g. 'de', 'fr'), new keys are initialized as empty strings ("") ready to be translated.
    • Existing translations are untouched: The extractor never overwrites text you or a translator have already completed.
    • Unused keys are preserved: If a string was removed from React source code, its translation remains in the catalog so previous work is never lost.
  5. Alphabetical Sorting: Keys are sorted alphabetically within each locale to guarantee clean, readable git diffs.
  6. File Writing: Writes directly to cms/translations.ts (or cms/translations.json if your project uses JSON).

Example Terminal Output

Terminal Output: pnpm forgewp i18n:extract
FORGEWP I18N EXTRACTOR

  Scanning files in: src/
  Extracted 42 unique translation keys.

----------------------------------------
  Extraction completed successfully!
  File: cms/translations.ts
  Locales: en, de, fr, it
  New keys discovered: 6
  Unused keys remaining in catalog: 0 (retained to prevent work loss)

Before and After Catalog Comparison

Notice how running extraction adds the new keys while preserving completed translations:

cms/translations.ts
1export const translations = defineTranslations({
2   en: {
3     "All rights reserved.": "All rights reserved.",
4     "Book Your Stay": "Book Your Stay",
5+    "Check availability": "Check availability",
6     "Discover Luxury": "Discover Luxury",
7   },
8   de: {
9     "All rights reserved.": "Alle Rechte vorbehalten.",
10     "Book Your Stay": "Aufenthalt buchen",
11+    "Check availability": "",
12     "Discover Luxury": "Luxus entdecken",
13   },
14   fr: {
15     "All rights reserved.": "Tous droits reserves.",
16     "Book Your Stay": "Reservez votre sejour",
17+    "Check availability": "",
18     "Discover Luxury": "Decouvrez le luxe",
19   },
20 })

Translate Missing Entries with pnpm forgewp i18n:translate

Once missing keys are extracted as empty strings, you can translate them automatically using:

Terminal
pnpm forgewp i18n:translate

Core Architecture Principles

  • Build-Time Only: Auto-translation happens strictly at build/CLI time. No external translation API requests are ever made while visitors browse your live WordPress site.
  • Catalog First: Existing nonempty catalog values are preserved. An optional response cache can restore blank entries before contacting the configured provider.
  • Saved Translation Data: The theme reads saved translations rather than calling a machine-translation provider for every visitor. Other site features retain their own network and hosting requirements.

Supported Translation Providers

ForgeWP supports four translation providers configured in wp.config.ts:

Table · 4 columns
Provider
Name
Internet Required?
Best For
Local / Manual
'local' or 'manual'
No
Agency handoffs, professional copywriters, custom manual editing.
DeepL API
'deepl'
At build time
Premium natural language translation quality (especially European languages).
Google Cloud
'google'
At build time
Maximum global language coverage across 100+ languages.
LibreTranslate
'libretranslate'
Optional (Local Docker)
Privacy-conscious teams, self-hosted offline translation pipelines.

1. Local / Manual Mode (Default)

In local mode, the CLI leaves missing keys as empty strings so you or your localization team can fill them in manually:

wp.config.ts (Local provider)
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4  name: "Alpine Resort",
5  slug: "alpine-resort",
6  i18n: {
7    locales: ["en", "de", "fr"],
8    defaultLocale: "en",
9    provider: "local",
10  },
11})

When you run pnpm forgewp i18n:translate with provider: "local", ForgeWP verifies that all keys exist and reports which keys need manual translation in cms/translations.ts.

2. DeepL API

DeepL offers the highest translation accuracy for marketing and editorial content. ForgeWP supports both DeepL API Free (keys ending in :fx) and DeepL API Pro.

Configure DeepL in wp.config.ts:

wp.config.ts (DeepL provider)
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4  name: "Alpine Resort",
5  slug: "alpine-resort",
6  i18n: {
7    locales: ["en", "de", "fr", "it", "es"],
8    defaultLocale: "en",
9    provider: "deepl",
10  },
11})

Set your API key in an environment variable or .env.local file:

.env.local
FORGEWP_DEEPL_API_KEY=your-deepl-api-key-here:fx

Alternatively, pass the key via providerConfig in wp.config.ts:

wp.config.ts (Inline API key fallback)
1i18n: {
2  locales: ["en", "de", "fr"],
3  defaultLocale: "en",
4  provider: "deepl",
5  providerConfig: {
6    apiKey: process.env.FORGEWP_DEEPL_API_KEY,
7  },
8}

ForgeWP automatically handles target language code mapping (for example, mapping en to EN-US as required by the DeepL API specification).

3. Google Cloud Translation API

For languages not yet covered by DeepL, Google Cloud Translation offers support for over 100 languages.

wp.config.ts (Google provider)
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4  name: "Alpine Resort",
5  slug: "alpine-resort",
6  i18n: {
7    locales: ["en", "ja", "ko", "zh"],
8    defaultLocale: "en",
9    provider: "google",
10  },
11})

Provide your API key in .env.local:

.env.local
FORGEWP_GOOGLE_API_KEY=your-google-api-key-here

4. LibreTranslate (Self-Hosted & Open Source)

For enterprise organizations, air-gapped systems, or agencies that need complete data privacy, LibreTranslate is a free, open-source machine translation engine that you can run locally via Docker:

Terminal: Start local LibreTranslate container
docker run -ti -p 5000:5000 libretranslate/libretranslate

Configure ForgeWP to point to your local instance:

wp.config.ts (LibreTranslate provider)
1import { defineConfig } from "@forgewp/compiler/define-config"
2
3export default defineConfig({
4  name: "Alpine Resort",
5  slug: "alpine-resort",
6  i18n: {
7    locales: ["en", "de", "fr"],
8    defaultLocale: "en",
9    provider: "libretranslate",
10    providerConfig: {
11      endpoint: "http://localhost:5000",
12    },
13  },
14})

Running pnpm forgewp i18n:translate will now send translation requests to your local Docker container with zero internet connectivity required.


Build Language Switchers with useWpLanguage()

To let visitors choose their preferred language, import the useWpLanguage() hook from @forgewp/react.

Properties Returned by useWpLanguage()

Table · 3 columns
Property
Type
Description
currentLanguage
string
The active language code (e.g. 'en', 'de').
languages
string[]
Array of all supported language codes declared in your configuration or Polylang.
urls
Record<string, string>
Map of language codes to their specific URLs for the current page (e.g. { en: '/about', de: '/de/ueber-uns' }).
homeUrls
Record<string, string>
Map of language codes to their respective homepage URLs (e.g. { en: '/', de: '/de/' }).
homeUrl
string
Homepage URL for the currently active language.
switchLanguage
(lang: string) => void
Navigation function that switches to the target language URL while preserving existing query parameters (window.location.search).

1. Server-Safe Static Language Switcher (0 KB JavaScript)

If your language switcher consists of standard navigation links (<a href="...">), ForgeWP leaves the component as 100% server-side rendered PHP HTML. No client hydration island is created, and zero JavaScript is sent to the browser:

src/components/StaticLanguageSwitcher.tsx
1import { useWpLanguage } from "@forgewp/react"
2
3export function StaticLanguageSwitcher() {
4  const { currentLanguage, languages, urls } = useWpLanguage()
5
6  return (
7    <nav
8      aria-label="Language"
9      className="flex items-center gap-2 text-sm font-medium"
10    >
11      {languages.map((lang) => {
12        const isCurrent = lang === currentLanguage
13        const targetUrl = urls[lang] || `/${lang}/`
14
15        return (
16          <a
17            key={lang}
18            href={targetUrl}
19            aria-current={isCurrent ? "page" : undefined}
20            className={`rounded px-2 py-1 uppercase transition ${
21              isCurrent
22                ? "bg-emerald-500/10 font-bold text-emerald-600"
23                : "text-slate-600 hover:text-slate-900"
24            }`}
25          >
26            {lang}
27          </a>
28        )
29      })}
30    </nav>
31  )
32}

Because <a href> links rely on standard browser navigation, this component requires zero client-side JavaScript.

2. Interactive Dropdown Switcher with switchLanguage()

When you need an interactive select element, dropdown menu, or custom popover, call switchLanguage(). ForgeWP automatically detects the click handler and selectively hydrates the switcher as an isolated client island:

src/components/LanguageDropdown.tsx
1import { useState } from "react"
2import { useWpLanguage, useWpI18n } from "@forgewp/react"
3
4const LANGUAGE_LABELS: Record<string, string> = {
5  en: "English",
6  de: "Deutsch",
7  fr: "Francais",
8  it: "Italiano",
9}
10
11export function LanguageDropdown() {
12  const [isOpen, setIsOpen] = useState(false)
13  const { currentLanguage, languages, switchLanguage } = useWpLanguage()
14  const { __ } = useWpI18n()
15
16  return (
17    <div className="relative inline-block text-left">
18      <button
19        type="button"
20        onClick={() => setIsOpen((prev) => !prev)}
21        aria-expanded={isOpen}
22        aria-haspopup="true"
23        aria-label={__("Select language")}
24        className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-xs hover:bg-slate-50 focus:outline-none"
25      >
26        <span className="font-semibold uppercase">{currentLanguage}</span>
27        <span>{LANGUAGE_LABELS[currentLanguage] || currentLanguage}</span>
28        <svg
29          className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
30          fill="none"
31          stroke="currentColor"
32          viewBox="0 0 24 24"
33        >
34          <path
35            strokeLinecap="round"
36            strokeLinejoin="round"
37            strokeWidth={2}
38            d="M19 9l-7 7-7-7"
39          />
40        </svg>
41      </button>
42
43      {isOpen && (
44        <div className="ring-opacity-5 absolute right-0 z-50 mt-2 w-44 rounded-lg border border-slate-200 bg-white py-1 shadow-lg ring-1 ring-black">
45          {languages.map((lang) => {
46            const isCurrent = lang === currentLanguage
47
48            return (
49              <button
50                key={lang}
51                type="button"
52                onClick={() => {
53                  setIsOpen(false)
54                  switchLanguage(lang)
55                }}
56                className={`flex w-full items-center justify-between px-4 py-2 text-left text-sm transition ${
57                  isCurrent
58                    ? "bg-emerald-50 font-semibold text-emerald-700"
59                    : "text-slate-700 hover:bg-slate-100"
60                }`}
61              >
62                <span>{LANGUAGE_LABELS[lang] || lang.toUpperCase()}</span>
63                {isCurrent && (
64                  <svg
65                    className="h-4 w-4 text-emerald-600"
66                    fill="currentColor"
67                    viewBox="0 0 20 20"
68                  >
69                    <path
70                      fillRule="evenodd"
71                      d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
72                      clipRule="evenodd"
73                    />
74                  </svg>
75                )}
76              </button>
77            )
78          })}
79        </div>
80      )}
81    </div>
82  )
83}

Preserving Search Parameters and Filters

switchLanguage() automatically preserves the current page's URL search parameters. If a visitor is browsing a hotel catalog with filters active:

Active search parameters on English route
/suites/?max_price=400&view=mountain

Calling switchLanguage('de') navigates the visitor to:

Preserved search parameters on German route
/de/suiten/?max_price=400&view=mountain

Their filter selections, sorting parameters, and search keywords are preserved across language transitions.


Multilingual Forms and Client-Owned Field Sets

Handling internationalization in native forms requires distinguishing between two categories of form content:

  1. Developer-Owned Fields (fields): Defined in cms/forms/{name}.ts. Labels and placeholders are source strings translated via __() or the translation catalog.
  2. Client-Owned Fields (clientFields): Added and customized by content editors in the WordPress admin dashboard (Forms → Form Editor).

Configuring Per-Locale Client Field Seeds

When your theme supports multiple locales (i18n.locales.length > 1), pass a per-locale dictionary to clientFields.seed so each language initializes with appropriate localized field labels:

cms/forms/inquiry.ts
1import { defineWpForm } from "@forgewp/react/config"
2
3export const form = defineWpForm({
4  mailTo: "admin",
5  subject: "New Chalet Inquiry",
6  submissionTitle: "Inquiry - {name}",
7
8  // Developer-owned fields (translated via useWpI18n / gettext)
9  fields: {
10    name: { type: "text", label: "Full Name", required: true },
11    email: { type: "email", label: "Email Address", required: true },
12    message: { type: "textarea", label: "Message", required: true },
13  },
14
15  // Client-owned fields (seeded per-locale, managed in wp-admin)
16  clientFields: {
17    enabled: true,
18    seed: {
19      en: [
20        {
21          name: "guest_count",
22          label: "Number of Guests",
23          type: "number",
24          required: true,
25        },
26        { name: "room_type", label: "Preferred Suite", type: "text" },
27      ],
28      de: [
29        {
30          name: "guest_count",
31          label: "Anzahl der Gaste",
32          type: "number",
33          required: true,
34        },
35        { name: "room_type", label: "Bevorzugte Suite", type: "text" },
36      ],
37      fr: [
38        {
39          name: "guest_count",
40          label: "Nombre de personnes",
41          type: "number",
42          required: true,
43        },
44        { name: "room_type", label: "Suite preferee", type: "text" },
45      ],
46    },
47  },
48})

How submitWpForm() Validates by Language

When a visitor submits a native form on a German page, submitWpForm() automatically transmits the visitor's page locale:

REST endpoint submission payload with active visitor language
1{
2  "name": "Johannes Weber",
3  "email": "johannes@example.de",
4  "guest_count": 4,
5  "lang": "de"
6}

The WordPress server-side submission handler calls forgewp_forms_resolve_lang($submitted_lang):

  1. Loads the exact client-owned field definition registered for the German (de) tab in the admin.
  2. Validates field names, types, and required rules against what the visitor actually saw.
  3. Saves the submission in the forgewp_submission custom post type tagged with the de language attribute.

Best Practices for Multilingual Themes

1. Use Complete Natural Sentences as Keys

Avoid assembling translated sentences out of individual words:

Incorrect: Fragmented sentence assembly
1// Never assemble phrases from separate translated fragments:
2<span>
3  {__("We have")} {count} {__("rooms available")}
4</span>

Instead, use formatted strings with placeholders:

Correct: Full sentence key
1<span>{__("We have %s rooms available").replace("%s", String(count))}</span>

Translating complete sentences gives human translators and automated engines the grammatical context required for correct word order, declensions, and agreements.

2. Keep Keys Consistent Across Components

The translation key is case-sensitive and whitespace-sensitive:

Case sensitivity demonstration
1__("Book Your Stay") // Matches catalog key "Book Your Stay"
2__("Book your stay") // Treated as a separate, distinct key!

Pick a consistent capitalization convention for buttons and navigation items across your design system.

3. Provide hreflang and Canonical SEO Tags

Ensure your site layout outputs language alternates for search engines using useWpLanguage:

src/components/LanguageMetaTags.tsx
1import { useWpLanguage } from "@forgewp/react"
2
3export function LanguageMetaTags() {
4  const { languages, urls } = useWpLanguage()
5
6  return (
7    <>
8      {languages.map((lang) => (
9        <link
10          key={lang}
11          rel="alternate"
12          hrefLang={lang}
13          href={urls[lang] || `/${lang}/`}
14        />
15      ))}
16    </>
17  )
18}

Troubleshooting & Common Pitfalls

Problem: Running i18n:extract does not discover my strings

Cause: The strings were passed as template literals with expressions (__(Hello ${name})) rather than static string literals.
Fix: The AST extractor requires static strings (__("Hello")). Use string concatenation or post-replacement for variables: __("Hello").concat(" ", name).

Problem: DeepL translation fails with "API error 403"

Cause: Using a Free API key with the Pro endpoint, or missing the :fx suffix.
Fix: Verify your key in .env.local. If you have a DeepL Free plan, your key must end in :fx (for example, 38b4...:fx). ForgeWP automatically selects the free endpoint (api-free.deepl.com) when :fx is present.

Cause: An onClick event handler was attached to navigation elements.
Fix: If you only need visitors to follow links to other language pages, use standard <a href={urls[lang]}> links instead of onClick={() => switchLanguage(lang)}. ForgeWP's Smart Discovery will leave the component as 100% server-side rendered PHP HTML.

Problem: Editing translations in wp-admin has no effect

Cause: The Polylang String Translations cache has not been refreshed.
Fix: After updating catalog files or exporting your theme, visit Languages → String Translations in the WordPress admin to let Polylang detect updated strings registered via pll_register_string.


Frequently Asked Questions

Can I mix DeepL auto-translation with manual revisions?

Yes. When you run pnpm forgewp i18n:translate, DeepL populates untranslated keys. You can open cms/translations.ts at any time and revise specific phrases. ForgeWP will never overwrite strings you have edited.

How does ForgeWP handle Right-to-Left (RTL) languages like Arabic or Hebrew?

WordPress automatically injects the rtl class into the <body> element when an RTL locale is active. You can style RTL layouts using CSS logical properties (margin-inline-start, padding-inline-end) or Tailwind's rtl: variant.

Does ForgeWP require Polylang to be installed on WordPress?

No. Polylang is optional. If Polylang is not installed, ForgeWP resolves translations through native WordPress gettext (<?php echo __('Text', 'theme'); ?>) and the active site locale from get_locale(). Installing Polylang enhances your site with per-post language linking and admin UI string editing.


Next Steps

Now that you have configured internationalization, extracted translation keys, and implemented language switching, proceed to Exporting and Production Deployment to learn how to build, validate, and package your production WordPress theme.

Reuse successful translation responses

Add a cache path when the translation command should reuse previously generated translations even if a catalog entry becomes blank again:

wp.config.ts (translation response-cache fragment)
1i18n: {
2  locales: ["en", "fr"],
3  defaultLocale: "en",
4  provider: "deepl",
5  cache: ".forgewp/i18n-cache.json",
6  providerConfig: {
7    apiKey: process.env.FORGEWP_DEEPL_API_KEY,
8  },
9}

Run pnpm forgewp i18n:translate after extracting keys. For each target language, the command preserves existing catalog values, restores matching cached values for blank entries, then asks the provider only for remaining misses. Successful nonempty responses are saved after each language batch, so an earlier batch remains reusable if a later provider request fails.

The cache distinguishes provider, configured endpoint, source language, target language, and exact source text. API credentials are not written into its keys or metadata. Changing a source string, language pair, provider, or endpoint creates a different lookup. Rotating an API key does not invalidate previously cached results.

The cache is optional: omitting cache keeps the normal catalog-only workflow. Local/manual mode does not read or write the response cache. The configured path must be a project-relative .json file inside the theme and separate from cms/translations.json and package.json. An invalid existing cache stops the remote translation command with a recovery message rather than overwriting that file.

The catalog remains the source of truth: an edited, nonempty translation takes precedence over a cached response. To request a fresh machine translation, clear the intended target entry and remove the response cache or choose a new cache path before running the command. Removing a cache alone does not replace existing catalog translations. A cache under .forgewp/ is removed by forgewp clean; the CMS translation catalog is separate.