UI Text
Reword any interface string for your institution — in English, Spanish or French — with no rebuild and no deploy.
Administration → UI Text (/admin/ui-text), gated on ADMIN.UI_TEXT (migration 225).
What this is for
The interface ships with roughly 8,900 strings. Institutions disagree about wording: one calls it a bulletin, another a catalog; one says Advisor, another Faculty Mentor. Rather than fork the frontend, any shipped string can be overridden here.
You can edit wording. You cannot add a string or remove one — see What you cannot do.
Draft, publish, history
Editing writes to a draft. Nothing you type is visible to users until you publish.
| Action | Effect |
|---|---|
| Edit a string | Saved to draft/ |
| Publish | draft/ becomes live/, and the previous live/ is snapshotted into history/<stamp>/ |
| Discard draft | Throws away unpublished edits |
| Export | Downloads the overlay as JSON |
The history snapshots are also this feature's audit trail — each publish records its version, timestamp and who published it.
Not stored in the database
This is the part that surprises people. The overrides are plain JSON files under LOCALE_STORAGE_PATH, one directory per deployment:
locales/
draft/{en,es,fr}.json what an administrator is working on
draft/_source.json generated: key -> shipped English at the time of the edit
live/{en,es,fr}.json what is served
live/_source.json
live/_version.json {"version", "published_at", "published_by"}
history/<stamp>/ a snapshot of live/ at each publishFiles rather than rows is what makes "per institution" work without a tenant column: /sdasis uses ./locales, the demo uses ./locales-demo, and /records uses ./locales-records.
In production this must be a bind mount
LOCALE_STORAGE_PATH has to point at a bind-mounted directory. If it points inside the container's own filesystem, every published override is lost the next time the container is recreated — which happens on any deploy.
The overlay is sparse (only the keys you changed) and flat ({"auth.login.title": "..."}), so a release that rewords other strings or adds new ones still reaches your deployment without conflict.
What you cannot do
Add a key. The write endpoint refuses any key that is not in the embedded shipped catalog: "…is not a key in this version of the interface". The usual cause is an overlay entry that survived a release which renamed or removed the key.
Remove a key. There is no delete. Clearing an override restores the shipped wording — which is different from setting it to an empty string, and an empty string is rejected with "Text is required. Clear the override to restore the shipped wording."
That restriction is visible in the route table itself: there is no POST-to-create and no DELETE on a key.
Validation, and the one silent failure it prevents
An override is checked against the shipped English, for every locale — English is what names the parameters the t() call actually passes.
Placeholders must be a subset. Dropping {count} from "Showing {count} results" is a legitimate rewording. Inventing {total} is not, because nothing passes it and it would render as the literal text {total}. The error names what is available.
Plural branch counts must match exactly.
This is the rule worth understanding
vue-i18n reads a top-level | as a plural separator. So rewriting "Yes" as Yes | No renders "Yes" forever, and nothing anywhere reports a problem — not the browser console, not the server log. Dropping the pipe from a two-branch plural is the mirror image: the singular form renders for every count.
The validator rejects both:
A '|' separates plural forms, so only the text before it would ever show. Write
{'|'}if you meant a literal pipe.
Write {'|'} for a literal pipe. The same escape covers {'{2}'} and {'@'}.
Linked messages (@:key) are rejected outright. No shipped string uses one, and allowing it would let an override pull in the text of an unrelated key.
Length is capped per override. The longest shipped string is 433 bytes; the cap leaves room for a much wordier rewrite without turning the overlay into a blob store.
Keys with a dot in them
The overlay is flat rather than nested because 21 shipped keys contain a literal dot, for example:
admin.academicPolicies.rules["DEGREE.TOTAL_CREDITS"].labelNesting one of those correctly requires the shipped key tree, which the backend does not have. The frontend does, and unflattens there. If you are editing the exported JSON by hand, keep it flat.
What happens at release time
deploy.ps1 never rewrites your overlay. It reports overrides that a release has:
- orphaned — the key no longer exists, so the override does nothing
- out of date — the shipped English changed after your override was written (tracked via
live/_source.json), so your wording may no longer say the right thing
deploy.ps1 -PruneLocaleOverrides drops the orphaned ones, behind a history snapshot.
The login page
The published overlay is served from a public endpoint (GET /core/ui-text/live), because the login page has to render your institution's wording before any session exists. It returns published interface strings only — no catalog, no personal data. That is strictly less than the branding endpoint beside it, which gives out the institution's name, logos and colours.
Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /core/ui-text/catalog | The shipped strings, to edit against |
| GET | /core/ui-text/draft | The working draft |
| PUT | /core/ui-text/draft | Write draft edits |
| POST | /core/ui-text/draft/discard | Throw away the draft |
| POST | /core/ui-text/publish | Draft becomes live; previous live snapshotted |
| GET | /core/ui-text/export | Download the overlay |
| GET | /core/ui-text/live | Public — the published overlay |
The catalog comes from a go:embed of a generated copy of the frontend's locale files, because the catalog in the browser is compiled message AST rather than strings. Developers regenerate it with npm run i18n:emit-catalog; npm run build fails if it is stale.
Related
- Lookup Tables — reference-data display values, which are translatable separately via
display_value_es/display_value_fron the ten lookup tables that carry those columns - Core API — the
ui-textandemail-templatesendpoints. Email template wording is edited through its own endpoint, not through this overlay