The whole migration of a plugin, of which this is one step, is in MIGRATE_PLUGINS.md.
Two kinds of component are involved: this theme's interface strings, and the page content of each plugin site. They use different file formats and behave differently, so they are described separately.
Settings below mirror the existing Galette components (checked against
galette/plugin-oauth2), so a new component sits in the project the same way the
others do.
- The Weblate GitHub App must be installed on the repository, and granted
write access. Every Galette component uses
vcs: github-appwith an empty push URL — Weblate pushes through the app and opens pull requests, so no deploy key is needed. A component created before the app is installed will fail on its first push, not on creation. - For a plugin site the branch is
gh-pages, not the default branch. Getting this wrong is silent: Weblate happily translates files fromdevelopthat the site never serves. - Every write Weblate makes to
gh-pagestriggers a Pages rebuild. That is wanted, but it meanspush_on_commitandcommit_pending_agedecide how often the site rebuilds. The Galette components usepush_on_commit: trueandcommit_pending_age: 24.
Menu labels, the maintainer sentences, the download cartouche, the footer — about twenty strings shared by every plugin site.
| Setting | Value |
|---|---|
| Repository | https://github.com/galette/theme-ghpages.git |
| Branch | main |
| File format | YAML file (API yaml) |
| File mask | i18n/strings/*.yml |
| Monolingual base language file | i18n/strings/en.yml |
| Template for new translations | i18n/strings/en.yml |
| Source language | English |
| License | GPL-3.0-only |
| Merge style | Rebase |
Ten languages already have a catalogue and will be imported as translated; the nine others appear empty and fall back to English on the sites until filled.
i18n/languages.yml is deliberately outside the mask. It is reference data —
language codes, autonyms, text direction — not translatable content. A mask of
i18n/*.yml would make Weblate treat languages as a language code.
Same reasoning for _data/galette.yml on a plugin's Pages branch: it is
generated at each release by galette/.github/actions/release-plugin and holds
version numbers, so no component mask may reach it. The current masks
(*/index.md, */documentation.md) do not, and a commit from that action on the
same branch is what Weblate will occasionally have to rebase onto — harmless, it
never touches a page.
The Liquid the theme ships is generated from these YAML files, so bin/build-i18n
has to run once translations land. The Execute script add-on is not available
on hosted.weblate.org, so .github/workflows/i18n.yml does it instead: on a push
to main touching i18n/strings/**, i18n/languages.yml or bin/build-i18n, it
regenerates the two includes and commits them if they changed. Nothing runs for
an ordinary commit.
Two things make it safe against looping: its own commit touches only
_includes/, which no path filter matches, and a push authenticated with
GITHUB_TOKEN does not start workflows.
The CI drift check therefore warns rather than fails. A Weblate pull request
only ever touches i18n/strings/*.yml, so the includes are stale until the merge
— expected, and not something to block on. On a human pull request the same
warning is the reminder to run the script.
If main is protected, the workflow's push is refused. Either allow
github-actions[bot] to bypass the restriction, or give the workflow a token
that can. There is no way around it with the default GITHUB_TOKEN.
A site using remote_theme downloads this repository when Pages builds it, so it
keeps serving the previous version of the theme until Pages builds it again —
nothing triggers that on its own. bin/rebuild-consumers, run by
.github/workflows/rebuild-consumers.yml, asks for one.
The sites are discovered, not listed: every non-archived repository of the
organisation that has a Pages site whose _config.yml references
galette/theme-ghpages. A hand-kept list went stale twice over — the day
plugin-fullcard moved between organisations, and the day stripe, legalnotices
and helloasso adopted the theme on a gh-pages-galette-theme branch of their
own. Discovery also means a repository that does not use the theme is never
rebuilt for nothing.
The scan follows the Pages source rather than assuming it: the branch can be
anything, and a source under a subdirectory has its _config.yml there. The
match is on the theme slug, not a whole line, so pinning to a tag
(galette/theme-ghpages@v1) still counts.
DRY_RUN=1 ./bin/rebuild-consumers # list the sites without rebuilding
THEME_ORG=… THEME_SLUG=… EXTRA_REPOS=… # scan elsewhere, or add repos outside the orgThe workflow exposes the same dry run through Run workflow.
It runs when _layouts/, _includes/, _sass/ or assets/ change on main,
and also after Regenerate i18n completes. That second trigger is not
redundant: the regeneration commits _includes/ with GITHUB_TOKEN, and such a
push starts no workflow, so a translation update would otherwise never reach the
sites.
It needs a token, and this is why. GITHUB_TOKEN is minted for the workflow's
own repository and cannot act on another one, whatever permissions the workflow
declares — so it can never rebuild a plugin site. The workflow reads
secrets.CONSUMER_PAGES_TOKEN instead, which needs the Pages repository
permission at write on the consuming repositories. An organisation secret
shared with this repository covers them all at once. A fine-grained token limited
to Pages is enough; it needs no code access.
Without the secret the workflow does not fail, it warns and rebuilds nothing — so a fork or a contributor is not blocked. When a rebuild is refused it does fail, because a site silently serving a stale theme is the thing this exists to prevent.
One component per page, because a component maps one file mask. Rather than creating them by hand, create one and let the Component discovery add-on create the rest — it also picks up a page added later.
Add new translation component, From version control:
| Setting | Value |
|---|---|
| Component name | Fullcard site: documentation |
| Repository | https://github.com/galette-plugins/plugin-fullcard.git |
| Branch | gh-pages — not the default branch |
| File format | Markdown file |
| File mask | */documentation.md |
| Monolingual base language file | documentation.md |
| Template for new translations | documentation.md |
| Source language | English |
| License | CC-BY-SA-4.0 — the pages, unlike the theme, see LICENSE.contents |
In the creation form, before saving, under File format parameters — not
afterwards. Until Translate front matter values is on, the front matter is not
a translatable unit, so Weblate's first write copies the source one over every
language file and destroys every translated title. Enabling it later re-reads
files that are English by then and records English as the translation, so the
loss is silent. fullcard lost eight titles exactly this way.
- Translate front matter values — on. The pages carry
titleanddescriptionin their front matter, and the tagline in the header comes fromdescription. Without this the front matter is offered as one opaque block. - Deduplicate identical strings — on. It keeps translations stable when a table row or a section moves.
Manage → Add-ons → Component discovery (weblate.discovery.discovery):
| Field | Value |
|---|---|
| Regular expression | see below |
| File format | Markdown file |
| Customize the component name | Fullcard site: {{ component }} |
| Define the monolingual base filename | {{ component }}.md |
| Define the base file for new translations | {{ component }}.md |
| Clone add-ons from the main component | on |
| Remove components for inexistent files | off, at least to start with |
(?P<language>[a-z]{2,3}(?:_[A-Z]{2})?)/(?P<component>[^/]+)\.md
Both groups are open by shape, not by list: a page added later is picked up, and so is a language, which is the point — languages arrive on a translator's request, not on a release. The same expression fits every Galette plugin site.
Weblate derives the file mask by replacing the language group with *, giving
*/index.md and */documentation.md — and */installation.md the day such a
page appears. The add-on runs on installation and after every repository update.
en never matches: the English pages are the base files, at the root.
The add-on discovers files, so it needs at least one language directory to
exist. A site published in English only — which is how a plugin now starts,
since nothing hand-translated should be committed — gives it nothing to match,
and no component is ever created. Create one component per page by hand instead;
auto has two, documentation and index. Add the add-on later, when a third
page or the first language directory exists, and it will pick the rest up.
A hand-made component on such a site is created, but flagged at once: No
translations found for this component file mask (NoMaskMatches). The
project's Add missing languages add-on (weblate.consistency.languages) fills
the component with the project's languages, but on its own schedule, not at
creation. To clear the alert at once, add one language — POST …/translations/ with {"language_code": "fr"} — and Weblate writes
fr/index.md from the base file — the English copy described under
Recovering the old catalogues — and opens a
pull request with it. Its statistics can read zero for a few minutes afterwards;
they catch up without a pull. Measured on stripe and legalnotices.
The one thing to watch: a two or three letter directory holding a .md
would be taken for a language — doc/, img/, api/, css/ all fit the shape.
images/, where the screenshots go, is too long to match, and that is the only
such directory these sites have. If you ever add a short one, exclude it with a
negative lookahead: (?P<language>(?!doc|img)[a-z]{2,3}….
File format parameters are not part of the add-on's configuration. Cloning add-ons does not clone them, so check Translate front matter values and Deduplicate identical strings on each component the add-on creates.
File format parameters, both components:
- Translate front matter values — on. The pages carry
titleanddescriptionin their front matter, and the tagline in the header comes fromdescription. Without this the front matter is offered as one opaque block. - Deduplicate identical strings (
markdown_merge_duplicates) — on. It keeps translations stable when a table row or a section moves.
Do not put the theme's admonitions in a page Weblate writes. Measured on maps, twice:
{% include alert.html type="warning" content="…" %}— the include this cost us, and which no longer exists — becomes one unit carrying the whole Liquid tag. Weblate re-wraps the file atline_max_length, an include tag has to hold a single line, and the Pages build then fails on every language file — the untranslated ones too, since the English source is re-wrapped as well. French had also closedcontent="with a guillemet.- A blockquote with a kramdown attribute list —
{: .admonition .admonition-warning}— produces the include's own markup from plain Markdown, but Weblate's serialiser folds the attribute list into the paragraph text: the classes stop applying and the braces show as content. It fails softly rather than breaking the build, but it fails.
What survives is a quoted paragraph opening with a bold label:
> **Warning** — Check the usage policy of the provider you choose.The theme styles blockquotes already, the label travels inside the sentence so it gets translated (the include hardcoded it, capitalised), and no round-trip can break it. Keep the admonition include for pages no translator touches.
line_max_length: 80 is not the culprit and is worth keeping: it reflows the
Markdown so a one-word change touches one line in the diff instead of a whole
paragraph. It only ever hurts content that is not prose.
There is deliberately no identifier in the front matter: the theme derives the
language from the directory and pairs a page with its translations by file name,
so title and description are the only keys, and both are meant to be
translated. Nothing here has to be marked read-only.
The Markdown format is monolingual, and Weblate does not read changes to translations back from the repository. The documentation is explicit:
Unlike most other formats, the changes in the translation files will not be imported to Weblate because it can not be done reliably. The source of truth for the translations is Weblate not the translated file.
It says changes, and that is the whole difference. A translation already in
the branch when the component is created is imported: helloasso's French page,
committed as fr/index.md before its component existed, came in with 35 of 37
units translated. The other two read the same in both languages (Galette HelloAsso, Installation), and same_edit flagged them for review.
What is ignored is a file that changes afterwards. A translated
de/documentation.md committed, or edited, once the component exists counts for
nothing: Weblate keeps its own units and, on its next write, replaces the file
with its own output.
The order this forces:
- Commit the translated pages first, then create the component — or let component discovery create them, since it only runs on what is already there.
- Anything that arrives later goes through Weblate: per component, per language, Files → Upload translation. An explicit upload goes through the parser and does populate Weblate, unlike a repository change.
- If Weblate has already flattened a file, nothing is lost: the content
stays in the branch history, so
git show <commit>:de/documentation.mdgets it back for an upload.
Creating or updating a component starts a chain of background jobs, and Weblate
locks the component while they run. Anything else is refused with 423 Locked — adding a language included, which is how three of maps' eleven
silently failed to be created. Wait for the lock instead of retrying:
curl -s -H @hdr https://hosted.weblate.org/api/components/galette/<slug>/lock/A linked component has its own lock, so check both. The English units appear only
once that first scan is done: until then the component reports zero strings and
looks broken. A POST /repository/ with {"operation": "pull"} brings the scan
forward once the lock is clear.
That check is advisory, not a reservation. Weblate can take the lock between
the moment it answers false and the moment the next request arrives — two of
eleven languages came back 423 on events despite waiting for each one. So
retry the request itself, treating 423 as "wait and try again" and 400 as
"already there", rather than trusting the lock endpoint and moving on. A request
lost that way leaves no trace in the interface: the language is simply absent.
Symptom: its previous pull request was merged, the next one shows a conflict or refuses to merge, and Weblate itself reports nothing pending on either component.
Cause: these repositories do not allow merge commits, so a Weblate pull request
is squashed. Its original commits are then no longer ancestors of the branch, and
if Weblate opens the next request before pulling that squashed tip, it re-proposes
what is already there. GitHub can still report it mergeable, while
rebaseable is false — which is what disables the button when rebase is the only
method left.
Check before touching anything, because the answer decides everything: compare each file in the request against the branch. If every blob matches, the request carries nothing.
for f in $(gh pr view <n> --repo <repo> --json files --jq '.files[].path'); do
a=$(gh api "repos/<repo>/contents/$f?ref=<pr head sha>" --jq .sha)
b=$(gh api "repos/<repo>/contents/$f?ref=gh-pages" --jq .sha)
[ "$a" = "$b" ] || echo "differs: $f"
doneThen close it and reset the component's working copy — the translations live in Weblate's database, so a reset discards only the diverged git branch:
curl -X POST -H @hdr -H 'Content-Type: application/json' \
-d '{"operation": "reset"}' \
https://hosted.weblate.org/api/components/galette/<parent slug>/repository/Afterwards weblate_commit and remote_commit read the same revision, and the
next request is built from the current tip. This is not worth a policy change:
maps merged five Weblate requests in a row without trouble, so the divergence
usually resolves itself on the next pull.
The Sphinx .po files hold the translations the manual accumulated. Two ways in,
and they are not equivalent.
The translation memory, which is the one to use. One multilingual TMX per
plugin, imported in Manage → Translation memory → Import, then Tools →
Automatic translation on each component with Translation memory as the
source and a 100 % threshold. It writes only the units it actually matches,
so a unit the catalogue never covered simply stays untranslated — visibly, in the
progress bar. The import form asks for no language: import_tmx reads srclang
from the header and every <tuv xml:lang> of each unit, and its two selectors
are enforced only for .xliff, .po and .csv. One unknown code aborts the
whole import, it is not skipped.
Build the memory against the units Weblate parsed, not against the file. Two differences make a source that looks identical fail to match:
- the Markdown parser replaces every link target with a positional placeholder,
so
[Club 404](https://…)is[Club 404]{1}in the unit — a memory carrying the URL matches nothing at 100 %, and the same conversion has to be applied to the translations or Weblate writes a broken link; - the front matter
titleis a unit of its own that no documentation catalogue ever held. It is in the theme's own strings, ast_nav_doc.
So close the loop before importing: read the English units back and count the sources that match exactly.
curl -s -H @hdr '…/api/translations/galette/<component>/en/units/?page_size=100'On auto that took the memory from 18 of 24 units to 21. The three left are the page description and the two download bullets, which have no old translation at all — and that is visible rather than guessed.
But first, know what "add new translation" does. It copies the base file, so
every unit of the new language immediately holds the English source as its
translation, and the same_edit add-on then flags them as needing edit. Two
consequences, both of which cost a detour on auto:
- Automatic translation filtered on
state:emptymatches nothing — no unit is empty. The API answersno strings were updatedand looks like a memory problem when it is not. - A script that skips units with a non-empty target skips every single one.
This is also the mechanism behind trap 8: the English copy is what a first write pushes back to the repository, and what a later read records as the translation.
Writing the units directly, which is what worked. With the catalogues already
converted, PATCH /api/units/{id}/ with {"target": ["…"], "state": 20} puts
each translation where it belongs, and the condition to test is whether the
current target equals the source, not whether it is empty. Units with no
translation at all get {"target": [""], "state": 0} — untranslated is the
truthful state, and it is what makes the gap visible.
On auto: 21 of 24 units for seven languages, 19 for German, in about four
minutes of API calls. Words that are identical in both languages —
Documentation, Installation — stay flagged as needing edit, which is exactly
what that add-on is for.
A prepared <lang>/page.md, only as a fallback. A monolingual Markdown
upload maps units by the file's structure, so every block has to be there,
including the ones the catalogue never translated — and those carry the English
source. Uploading such a file records English as that language's translation for
each of them: trap 9 again, in miniature. Measured on auto: the best languages
still carry two English units out of twenty-two, German five.
So the order is: import the TMX, create the components, add the languages, run
automatic translation, and only then consider uploading anything. If you do
upload, clear what came in identical afterwards — filter the component on
check:same and bulk-edit, which is also how the fullcard leftovers get cleaned.
Keep doc-plugins-fullcard until step 3 is done. Its strings feed the project
translation memory, so a translator is offered the old wording as a full match
instead of retyping it — and it is the safety net if an upload goes wrong.
Also worth knowing: Weblate labels this format's support as under development, with behaviour that may change between releases. Worth a check after a Weblate upgrade.
A language new to Galette must be declared in i18n/languages.yml — its code,
its autonym computed as the header of that file explains, and rtl: true when
Galette lists it as right-to-left. Weblate creates i18n/strings/<lang>.yml but
cannot touch that file, which stays outside the mask; until the entry exists the
catalogue is ignored and the language is missing from the picker (Croatian,
#19). The Regenerate i18n workflow rebuilds the includes once it lands on
main. A declared language with no translated strings yet renders the English
ones while keeping its own lang attribute and text direction.
On the plugin site, add its defaults entry in _config.yml — plugin-fullcard
already lists the nineteen that existed before Croatian — and Weblate can then create the page files itself.