=== PixelHunter Translate ===
Requires at least: 6.7
Requires PHP: 8.1
Tested up to: 7.1
Stable tag: 0.8.6
License: proprietary

Translate WordPress sites with AI, with dedicated support for
WooCommerce stores. New and edited content is translated in the background; the
front end only reads.

== Description ==

The whole idea of this plugin is one separation: **translation time** happens
in the background, and **serving time** is read-only.

The text of the site is read straight from the database — products,
categories, pages, menus, the shop settings — plus one page of each template.
It is sent to the PixelHunter translation service, and from then on the site
serves its pages from a dictionary compiled to a file. A product you publish
is translated a few seconds later. No visitor triggers a translation, no page
waits on an API, and the characters of your license follow the text you add —
not the traffic.

It is the Shopify Translate & Adapt model, and the opposite of the plugins that
translate HTML on demand: those pay for the translation again on every new page
a visitor opens.

== How it is used ==

1. **Languages** — pick the source language (the one the site is written in),
   add the target languages, each with its own URL prefix.
2. **License** — put in your license key. Then, if you want, a glossary and
   a brand tone under **Settings**.
3. **Dashboard → Translate the site** — reads the text of the whole site and
   sends what is missing to be translated, with a progress bar. You can close the
   page: the rest carries on in the background.
4. From then on, with **automatic translation** on (the default), whatever is
   published or saved is translated on its own, and the whole site is checked
   again once a week and after every theme or plugin update.
5. **Strings** — review and fix by hand. A reviewed translation is never
   overwritten by the machine again.

== The language switcher ==

It is configured on the **Switcher** tab, and it has three modes:

* **Automatic** (the default) — it shows up on its own, floating, without
  touching the theme. You pick the corner. It is what makes the plugin work for
  someone who has just configured a language.
* **Manual** — you place it where you want, with the `[pht_switcher]` shortcode
  in content or a widget, or with `<?php pht_switcher(); ?>` in a theme
  template, typically `header.php`.
* **Off**.

There are four shapes — a list, a column, a `<details>` menu and the system
`<select>` — and each one is previewed in the panel with this site's own
languages. You also choose between the language name (*English*, *Español*) and
the code (*EN*, *ES*), and whether an emoji flag comes before it.

The shortcode and the function work in all three modes. In automatic mode the
floating switcher steps aside on any page where you placed one yourself, so a
page never shows two. The floating switcher **does
appear on the cart and on the checkout**: hiding it there was the first
decision, and in practice it did worse, because whoever landed on the cart in
the wrong language was left with no way back to their own, at exactly the
moment they are spending money.

The **Appearance** section of the same tab sets the colours of the box and of
the languages (at rest, on hover and the current one), the text size, the
corners and the shadow, with a live preview next to it. It warns when a text
colour falls under the 4.5:1 contrast WCAG AA asks for.

It is about 4 KB of CSS, injected inline and only on the pages where the
switcher appears. Zero JavaScript in three of the four shapes: they are links,
and a link is the thing browsers and screen readers do best. The `<select>` is
the exception, and it takes eight lines. Everything the Appearance section sets
is a CSS variable on `.pht-switcher` (`--pht-bg`, `--pht-fg`, `--pht-hover`,
`--pht-radius` and the rest), so a theme can still redefine them without
needing `!important`.

== The suggestion bar ==

Whoever arrives in a language that is not theirs gets a small bar at the top
offering the version in their own. It suggests, it does not redirect:
redirecting by browser language breaks the page cache, and Google advises
against it. It shows once — choosing or closing answers for good.

== The license ==

A subscription gives characters every month and a number of sites for one
key: Starter (300,000 a month, 1 site), Pro (1,500,000 a month, up to 5 sites)
or Agency (6,000,000 a month, up to 25 sites), monthly or yearly at the price
of 10 months. Unused characters roll over while the subscription is active.
The free trial gives 30,000 characters for one site, once, with no card. The
key arrives by email after checkout and is always available in your
PixelHunter account, where you can also move it between sites. Changing plan
is done there too: each plan has its own key, so the key of the new plan
arrives by email and replaces the old one on your sites.

The best place for it is `wp-config.php`:

    define( 'PHT_LICENSE_KEY', 'PHT-...' );

That way it is not in the database, it does not show up in an export, and it is
not editable by someone who has the admin panel but not the server. The field
on the License tab is the alternative for whoever cannot touch
`wp-config.php`.

There is no API account or API key to set up: the translation service holds
its own key and picks the model. The plugin never calls the API directly.

Plugin updates need an active license; the free trial counts, with or without
characters left. If the subscription ends, everything already translated
stays online and the plugin keeps working on the version it has; only new
versions and automatic translation stop.

== Recording mode ==

The site is read as an anonymous visitor sees it. That never shows a cart with
items in it, a
checkout returning validation errors, or the message for an invalid coupon —
and that is where the strings that stand out most when left untranslated live.

To catch them: browse the site signed in as an administrator, with
`?pht_record=1` in the address. Whatever shows up is recorded and imported from
the Dashboard. It costs nothing to any visitor, at any moment.

== How it is built ==

* **No rewrite rules.** The language prefix is stripped from the request before
  the WordPress router touches it, so `/en/shop/` resolves like `/shop/` with
  the permalinks the site already has.
* **The core `WP_HTML_Processor`**, the WordPress HTML5 parser. With an empty
  dictionary the output HTML is byte for byte the input — `DOMDocument` lost
  7 KB and corrupted SVG, `<details>` and `<dialog>`.
* **A dictionary compiled to a PHP file**, inside `uploads/`. It sits in
  OPcache: serving a translated page costs neither a query nor an HTTP request.
* **The WooCommerce UI for free.** The `determine_locale` filter makes Woo load
  its own native translation pack — "Subtotal", "Proceed to Checkout", dates,
  currency and notices come translated by humans, at no API cost.
* **No build step, no jQuery, no runtime dependencies.**

Cost measured on the heaviest page of this store (262 KB, 719 replacements):
**~130 ms**, which is `38 ms + 0.12 ms per replacement` — linear, and almost
all of it spent inside the core `set_modifiable_text()`.

In the source language: **0 ms**. The plugin returns before opening the buffer.

Each language is a distinct URL, so the host page cache serves it once per URL
per TTL. On a store with no page cache, count on those 130 ms of TTFB on the
heaviest translated pages.

== Warnings ==

**Page cache TTL under 12 hours.** If the theme prints a nonce on every page —
as a login box in the header does — that nonce is good for 12 to 24 hours. On a
page cached for longer than that, the login fails silently. Each language is a
distinct URL and therefore a distinct cache entry, with exactly the same
restriction.

**The dictionaries are PHP files inside `uploads/`.** The plugin writes an
`index.php` and an `.htaccess` there, but on a server that serves PHP out of
`uploads/` that is not enough. If the host allows it, block PHP execution in
that folder.

== Addresses ==

Slugs are not translated — only the prefix changes. That is a decision, and it
takes code to hold it: WooCommerce registers the brand taxonomy with
`_x( 'brand', 'slug', 'woocommerce' )`, which returns `marca` on a Portuguese
site and `brand` on an English one. Since the rewrite rules in the database
were written with `marca`, changing the language gave a 404 on every brand.

The plugin learns the real slugs of the site on source-language requests and
puts them back on any translated request.

Some addresses never become HTML and so escape link rewriting: the "Proceed to
checkout" of the blocks comes from a JavaScript configuration object, and the
destination after placing an order comes from the Store API JSON. Those are
prefixed in PHP, through WooCommerce's own filters.

**Date format.** The format stored in the site options usually carries literals
from the source language — `j \d\e F \d\e Y` gives "9 de September de 2026" in
English, half translated. On translated pages the plugin uses each language's
own format (`September 9, 2026`).

== Sitemaps and SEO plugins ==

The plugin has no sitemap of its own, on purpose. The sitemap belongs to the
SEO plugin: it already announces it in `robots.txt`, and the site owner already
submitted it to Search Console. A second sitemap would have to be submitted by
hand, site by site. So the translated pages go into the sitemap that is
already there:

* **Yoast SEO, Rank Math and Slim SEO** — every page gets one `<url>` per
  language, and every `<url>` lists all the versions in
  `<xhtml:link hreflang>`, itself included. That is the format Google documents
  for multilingual sites.
* **No SEO plugin** — the core `wp-sitemap.xml` gets one sitemap per language
  (`wp-sitemap-translations-en_posts_page-1.xml`). The core accepts nothing but
  `loc` and `lastmod`, so there the translations go in as plain addresses; the
  `hreflang` is already in the `<head>` of every page.

On translated pages the canonical URL and `og:url` keep the language prefix
with any of the three SEO plugins. A canonical pointing at the source page
tells Google the translation is duplicate content, and it gets dropped from
the index.

Rank Math keeps its sitemap cached for up to 100 days, and Yoast can be told to
cache it too. Saving the languages clears both caches, so a new language shows
up in the sitemap right away.

== Updates ==

The plugin updates itself from PixelHunter's update server, not from
WordPress.org: new versions show up in Plugins → Installed Plugins, with
"View details" showing this changelog. The `Update URI` header keeps
WordPress.org from ever offering a plugin with the same slug over this one.

The update server is a Cloudflare Worker whose address only exists inside the
plugin; the source code and the archive of every version live in private
repositories. No token or password ships inside the plugin.

Every package is signed with an ed25519 key, and the plugin checks the
signature before WordPress installs anything. A package that was swapped on
the server, or published by someone without the private key, is refused.

**To publish a version:** raise `Version` in `pixelhunter-translate.php`, the
`VERSION` constant and `Stable tag`, write the `= X.Y.Z =` section of the
changelog, push, and create a GitHub release with the tag `vX.Y.Z`. The
release workflow builds, signs and publishes it, and refuses to when any of
those do not match. Sites see it within 12 hours, or at once with
Dashboard → Updates → Check again.
The full step by step, with what each error means, is in `RELEASING.md` in
the source repository.

A copy of the plugin that is a git checkout never updates itself: the
installer would replace the folder, repository included.

== Known limitations ==

* **URL slugs are not translated.** `/en/produto/blusas-e-camisas/` keeps the
  source slug. Only the prefix changes.
* **Asynchronous emails go out in the source language.** An email triggered by
  a status change in the admin has no request to inherit the language from.
* **One string, one translation, across the whole site.** "Free" is always
  translated the same way, wherever it shows up. The "Where" column in the
  table gives the reviewer the context. It is the same limitation Shopify has
  for theme strings.
* **It does not translate inside `<script>`.** That includes the JSON-LD
  structured data of the SEO plugins.

== Translation packs: the half you do not pay for ==

A translated site has two sources of text, and neither replaces the other.

The **software interface** — "Subtotal", "Proceed to checkout", "Add a coupon",
error notices, dates, currency — comes from the official WordPress.org
translation packs. They are reviewed by humans and cost nothing. The **content
of the site** — product names, page text, menus, theme strings — comes from
this plugin's dictionary, translated once and served from a file.

This plugin changes the `locale` of the request. From there on, WordPress and
WooCommerce go looking for the translation files of that locale — and if they
do not find them, they serve the originals, which in WooCommerce are English.
The symptom is a Spanish site with the cart and the checkout in English, with
no error anywhere explaining why.

That is why the panel says which packs are missing and installs them with one
button. It takes a few seconds, and it avoids sending the API thousands of
interface strings that already exist translated, and translated better.

**Two notes for whoever works on this:**

* Packs are **not** installed when a language is saved, and that is deliberate:
  installing one downloads from WordPress.org and runs a bulk upgrade, and two
  languages added at once killed the request on `max_execution_time` — after
  the option had already been written, so the languages looked configured and
  nothing translated, with no error to explain it. The button does it one
  language at a time and reports what failed.
* Installing the core pack is not enough. The WordPress `bulk_upgrade()` does
  not look for what exists — it reads the update transients, and those were
  built when the new locale was not yet installed. Without forcing a check
  first, the core ends up translated and WooCommerce ends up with no
  translation at all.
* If the automatic install fails — a server with no route to the internet, or
  `wp-content/languages` with no write permission — the panel says which ones
  are missing and shows the WP-CLI commands that fix it.

== The cart and the checkout ==

They are blocks, and their content comes as JSON from the Store API — which the
HTML buffer does not see and should not see. They are covered, in three
different ways:

* **The labels** ("Subtotal", "Proceed to Checkout", dates, currency, notices)
  come from the native WooCommerce translation pack, reviewed by humans. Zero
  API cost.
* **The content** — product, variation and category names — is translated by
  the dictionary. A request to the Store API carries no language prefix at all,
  so the language is read from the `Referer`, validated against the site domain.
* **The text the store wrote** — shipping method names, payment method titles
  and instructions, attribute labels — is collected straight from WooCommerce
  when you click Detect. It shows up on no page the detector visits: an empty
  cart shows no shipping methods, and an empty checkout redirects.

Two notes for whoever works on this:

* The WooCommerce blocks keep visible text in `data-content`, on an empty
  element that JavaScript fills in later. That attribute is translated; the
  `data-wp-*` of the Interactivity API are **not**, because they hold code
  expressions.
* An order is always saved in the source language, even when it is placed in
  another one: the admin, the invoices and the reports read one language, and
  the translation happens on the way out. Only presentation filters translate
  order data — WooCommerce saves each item name through its `view` getter, so
  a filter there would write the translation back into the database.
* WooCommerce keeps the calculated shipping methods in the session and only
  recalculates them when the cart changes. The plugin adds the language to the
  "package", so each language gets its own entry instead of inheriting the
  other one's.

== Changelog ==

= 0.8.6 =
* **The Help tab starts with this site.** How many characters the text
  detected on the site adds up to, what translating it into your languages
  uses, how much is still to translate, and a warning when that is more than
  the characters left.
* **Every Help section links to the documentation** on
  pixelhuntertranslate.com, where the full text lives.
* **"What does this mean?"** next to an error on the Dashboard opens its line
  in the documentation's table of messages, with what to do about it.
* **Diagnostic info for support.** The Help tab shows what support needs to
  know (plugin, WordPress and PHP versions, plan, balance, languages, the
  last error), with a button to copy it and one that opens an email to
  support with it already filled in. It never includes the full license key.
* Support is now support@pixelhuntertranslate.com, and the plugin's site is
  pixelhuntertranslate.com.

= 0.8.5 =
* **An up-to-date translation template.** The `.pot` file in `languages/`
  now has every string of the panel as it is today, for anyone translating
  the plugin into another language. It had not been regenerated since 0.3.0.
* Internal cleanup of the code that talks to the translation API. Nothing
  changes in how the plugin works.

= 0.8.4 =
* The plugins list no longer shows "View details" twice. WordPress adds its
  own link once it has checked for updates, and the plugin now only adds
  one when WordPress has not.

= 0.8.3 =
* **New colours.** The plugin panel, its menu icon and the banner follow the
  new PixelHunter brand: a dark grid with a cyan pixel.
* **A fuller Help tab.** How yearly plans are credited (all twelve months at
  the start), the free trial rules (one per site, a real email address),
  translating by hand without a license, why deleting the plugin does not
  free its site or cancel the subscription, why plan changes go through My
  account and not the License tab, and what keeps working after cancelling.
* Clearer messages when the translation API is busy or unavailable: they
  say the batch will be retried in a moment, so there is nothing to do.

= 0.8.2 =
* **Lighter on the site while it translates.** Languages are translated one
  at a time instead of all together, at the same speed: each one is
  complete sooner, and visitors no longer see several languages half done.
* The translation files are rewritten at most once a minute while a
  language is in progress, instead of after every step. That was what made
  the site slower during a large translation.
* Translate the site shows which language it is on ("Translating German,
  2 of 6"), and says the site may be a little slower until it finishes.
* **Monthly or yearly on the License tab.** A switch shows each plan's
  yearly price and its characters for the whole year, credited at the
  start, and Subscribe opens the checkout on the yearly plan.

= 0.8.1 =
* Clearer help on changing plan: each plan has its own license key, so the
  key of the new plan arrives by email and has to be pasted on your sites.
* The License tab warns subscribers to change plan in My account instead of
  subscribing again, so nobody is charged twice.
* The automatic translation notice points to the Settings tab, where the
  option is.

= 0.8.0 =
* **Updates need an active license.** New versions download only with a
  valid license key, the free trial included. If the subscription ends,
  what is already translated stays online and the plugin keeps working on
  the version it has. A refused update says why on the Updates screen.

= 0.7.2 =
* An internal document of PixelHunter shipped inside the plugin folder in
  0.7.0 and 0.7.1. It carried no keys or secrets. It is removed on update.

= 0.7.1 =
* The Save button of the License tab sits next to the key field. It was at
  the bottom of the page, under the plans, where nobody looked for it.
* The plan cards lead with the price. Characters and sites come below, on
  the same lines in every card; the Pro card is the dark one.

= 0.7.0 =
* **Plans instead of top-ups.** Starter, Pro and Agency: characters every
  month and 1, 5 or 25 sites on one key, monthly or yearly. Unused characters
  roll over while the subscription is active. The License and Top-up products
  are no longer sold; keys already bought keep working with their balance.
* **One key, several sites.** A Pro or Agency key activates on each site that
  uses it, up to the sites of its plan. The top of every screen shows the
  plan and the sites in use, as in "Pro · 3 of 5 sites". Free a site from
  your account when you need the slot.
* **The balance is refreshed when you open the plugin,** not only after a
  translation. With a key shared by several sites, a quiet site showed an
  old number.
* Help and License tabs rewritten for the plans.

= 0.6.0 =
* **A license key instead of an API key.** The plugin no longer calls
  the API directly. Text is sent to the PixelHunter translation service, which
  checks your license, counts the characters and translates. There is no API
  account to set up and no model to pick.
* **Characters, not tokens.** Every license has a balance of characters. It
  shows at the top of every screen, with a bar. When it runs out, translation
  pauses and resumes on its own after a top-up.
* **One key per site.** A key activates on the first site that uses it. Move
  it to another site from your account, without asking anyone.
* **Translation → License.** Paste the key, see the balance, buy a license or
  a top-up. A free trial with 30,000 characters needs no card.
* **Help tab.** How the license works, moving it between sites, balance and
  top-ups, invoices and support.
* Sites that had an API key stop translating until a license key is
  entered. What is already translated keeps being served.

= 0.5.4 =
* On the Strings tab, the translation boxes no longer carry a coloured
  edge for their status. The status is already in the filter above the
  list; the edge was noise.

= 0.5.3 =
* **Order emails in the customer's language.** The emails sent after the
  purchase — paid, completed, refunded, a note from the shop — went out in
  the store's language, whatever language the customer had bought in. They
  now go out in the language of the purchase, from the subject to the
  footer: product, shipping and payment names, the date, and the text you
  wrote for the emails (subject, heading, additional content, footer).
  Emails to the shop administrator stay in the store's language.
* The plugin's own mark in the admin menu, in the brand colours.

= 0.5.2 =
* **A new look for the panel.** A header with the plugin's mark and the
  version, each subject in its own section, tables and forms with the same
  spacing and borders on every tab, and buttons in the brand colour. Only
  the plugin's own page changes: the rest of the admin and the site are
  untouched.
* On the Switcher tab, the settings are split into sections: where it
  appears, shape, how it reads, appearance, and placing it by hand.

= 0.5.1 =
* In automatic mode, the floating switcher no longer shows on a page where
  you placed one yourself with `[pht_switcher]` or `pht_switcher()`. Those
  pages showed two switchers; now they show only yours.
* On the Switcher tab, the previews change as soon as you pick the flags or
  the name/code option. Before, nothing changed until you pressed Save, and
  it looked as if the option did not work.

= 0.5.0 =
* **Nothing is left untranslated any more.** The detector only visited 40
  products and 40 categories of each kind: in a shop with 454 products, 414 of
  them — and 172 categories — were never translated, and the Dashboard still
  said there was nothing left to do. The whole site is now read, and the first
  check after this update translates what was left behind.
* **Automatic translation.** A product, page or category is translated a few
  seconds after it is published or saved, in the background. The whole site is
  checked again once a week and after every theme or plugin update. It is on
  by default and can be turned off in the Translation tab.
* **Much faster, and the site stays fast while it works.** The text is read
  straight from the database instead of opening every page of the site, and
  several batches go to the API at the same time. A whole shop is translated in
  under a minute, where it used to take over half an hour and slow the site
  down for whoever was browsing it.
* **About 60% fewer strings to pay for.** Sentences that join a name to
  template text — "/ Dresses", "Be the first to review “Summer Dress”", "Black,
  Blue", "5 items" — are now assembled from their translated pieces when the
  page is served, instead of being translated one by one for every product.
  This also fixes product names left in the source language inside
  WooCommerce sentences.
* **One button.** The Dashboard has a single **Translate the site** button with
  a progress bar, instead of Detect and Translate for each language.
* **Nothing gets stuck.** A text the API cannot translate is marked as Failed
  (Strings → Failed, to write by hand) and no longer blocks the rest. A wrong
  key or an account without credit pauses for an hour, with the error shown on
  the Dashboard.
* After translating, the translated versions of the pages that changed are
  cleared from the page cache (WP-Optimize, LiteSpeed, WP Rocket and W3 Total
  Cache).

= 0.4.1 =
* Updates come from PixelHunter's own update server instead of a public
  repository, so the plugin can only be downloaded by whoever already has it.
  0.4.0 was never distributed.
* A "View details" link next to the plugin, in Plugins → Installed Plugins,
  opens the description and this changelog at any time — not only when an
  update is waiting.

= 0.4.0 =
* **Automatic updates.** New versions show up in Plugins → Installed Plugins
  like any other update, and install with one click or on their own. Every
  package is signed, and the plugin refuses to install one whose signature
  does not match. This is the first version that updates itself: sites on an
  older version need this one installed by hand, once.
* The Switcher tab has an Appearance section: colours of the box and of the
  languages (at rest, on hover, current), text size, corners and shadow, with a
  live preview and a contrast warning. The defaults are the colours the
  switcher always had.
* In the bottom corners, the list and the column sat about 4px off centre in
  their box: a margin meant for the menu, which opens upwards there, was
  landing on them too.
* Orders placed in a translated language saved the product, shipping and
  payment names in that language. The dictionary is keyed by the source text,
  so from then on no language translated them — an order placed in Spanish
  stayed in Spanish in English, in German and in the admin. Orders now save
  the source names, and the language of the purchase in `_pht_lang`; the
  order pages and the email sent at checkout translate them on the way out.
* The notice that a product has been added to the cart kept the product name
  in the source language: WooCommerce builds it from `get_the_title()`, which
  skips the product filters. The name is now translated when the notice is
  created.
* Addresses are no longer translated. The API turned "rua" into "calle" in the
  shop's own address, and that entry then reached every page where the same
  address showed up. The prompt now tells the model to leave addresses, names,
  emails, phones and tax IDs exactly as written, and text inside `<address>`
  is never translated.

= 0.3.0 =
* Translated pages are now in the XML sitemap, with `hreflang` alternates:
  inside the Yoast SEO, Rank Math and Slim SEO sitemaps, or as one sitemap per
  language in the core `wp-sitemap.xml` when there is no SEO plugin.
* With Slim SEO active, the canonical URL of a translated page pointed at the
  source page. It now keeps the language prefix, as it already did with Yoast
  and Rank Math. `og:url` keeps it too, with all three.
* On a site installed in a subfolder (`example.com/shop/`), source-language
  pages sent the `hreflang` tags and the language switcher to addresses with
  the folder twice (`/shop/shop/…`), all of them 404. Translated pages and
  sites at the domain root were not affected.
* English is now the plugin's source language. Every string in the admin
  interface, in the front-end switcher and in the error messages was rewritten
  in English, and the `.pot` was regenerated from them. A site that wants the
  panel in another language translates it like any other plugin.
* The system prompt sent to the API is in English too. The source and target
  languages were already going in by their English names, so a Portuguese
  prompt naming "Spanish (Spain)" was mixing two languages in one instruction.
* The readme was rewritten in English, and two claims in it that were no longer
  true were corrected: the floating switcher does appear on the cart and on the
  checkout, and translation packs are installed by the panel button, not when a
  language is saved.

= 0.2.1 =
* Cleanup: excess code goes, without changing what the plugin does. One shared
  string translation instead of a copy in `Woo` and another in `Json`; the REST
  routes in a table instead of ten identical blocks; one `asset_version()`
  instead of three; the `/status` route, which nothing called, gone.
* The panel stops loading `wp-api-fetch` (and `wp-polyfill` behind it): the
  plugin JavaScript never used it.
* Uninstalling now also deletes the `pht_lang_locales` and `pht_source_slugs`
  options, which were left behind.

= 0.1.0 =
* First version.
