Multilingual Resources¶
Cesivi ships a built-in localization layer for runtime-configurable strings —
list titles, view names, web part headers, custom display names — without
re-deploying the application. This guide is for administrators and content
authors. For developers integrating with the resolver, see
_docs_dev/MULTILINGUAL-RESOURCE-STORE.md.
What it is¶
A resource is a key + a dictionary of translations:
{
"key": "lists.tasks.title",
"translations": {
"en-US": "Tasks",
"de-DE": "Aufgaben",
"fr-FR": "Tâches"
}
}
You reference a resource from any user-editable string by writing
{{t:key}}. So a list title of {{t:lists.tasks.title}} will render as
"Tasks" in English, "Aufgaben" in German, "Tâches" in French — picked from
the request's Accept-Language header.
If the requested culture has no translation, the resolver falls through a
five-step chain (per-site override → site default culture → system override →
system default → key literal). The key literal is the safe-stop fallback;
worst case, the user sees lists.tasks.title instead of a localized string,
but the page still renders.
When to use it¶
Use {{t:...}} placeholders for any string that needs to differ per user
language and that admins or end-users will edit at runtime:
- List titles, library titles, view names
- Web part chrome titles
- Custom display names on list columns
- Site logo alt text, navigation link titles
- Page titles created by power users
Keep using literal strings (no placeholder) for text that's purely structural or only ever appears in one language (internal admin labels, error codes).
Editing resources via the admin UI¶
Navigate to Cesivi Administration → System Settings → Multilingual
resources (URL: /_admin/multilingual.aspx). You will see:
- A filter bar at the top: scope (All / System / SiteCollection), site id, and a "key contains" search box.
- A table of resources with the key, scope, site, list of cultures, and last-modified timestamp.
- An + New resource button (top right).
Adding a new resource¶
- Click + New resource. A modal dialog opens.
- Enter the Key in dotted lowercase form:
lists.tasks.title,webparts.welcome.heading, etc. (Convention: namespace.module.thing.) - Choose the Scope:
System— global to the whole farm. Use for shipped, repository-tracked resources.SiteCollection— overrides the system value for one site collection. Useful when a single tenant wants different wording.- (SiteCollection only) Paste the Site Id (Guid).
- (Optional) Default text — used as the very last fallback before the key literal.
- Add translations. Pre-populated rows mirror the cultures already used
by other resources; type the localized value next to each. To add a
culture not yet listed, type a BCP-47 code (e.g.
ja-JP) in the "Add culture" box and click + Add culture. - Click Save. The page reloads and the new resource appears in the table. The change is visible in any rendered page immediately — the resolver cache is drained on every save.
Editing an existing resource¶
- Click Edit in the row's Actions column.
- The modal opens pre-populated. The Key field is read-only (changing the key is not supported — delete + create instead).
- Modify any translation, add/remove cultures, then Save.
Deleting a resource¶
Click Delete in the row's Actions column. After confirmation, the
resource is removed and any references to {{t:thatkey}} will fall through
to the next layer in the resolver chain — typically a system-scope value, or
the key literal if nothing else is defined.
Adding a new culture¶
Cesivi does not maintain a fixed list of supported cultures. Any BCP-47 identifier the .NET runtime accepts is valid. To add support for, say, Japanese:
- Open the admin UI for any resource (or create a new one).
- In the modal, type
ja-JPin the "Add culture" box and click + Add culture. - Enter the Japanese translation, save.
There is no separate "register a culture" step — populating a translation under a new BCP-47 code is sufficient.
Bulk import / export via PowerShell¶
For CI/CD, tenant rollout, and translation-vendor workflows, use the
PowerShell cmdlets shipped in Cesivi.PowerShell (module v3.1.0+).
Cmdlets¶
| Cmdlet | Purpose |
|---|---|
Get-CSResource |
Query one key or list with filters |
Set-CSResource |
Upsert one resource (Single or Bulk parameter set) |
Remove-CSResource |
Delete one resource |
Export-CSResources |
Write a JSON bundle to a file |
Import-CSResources |
Read a JSON bundle and apply it |
All cmdlets accept -WebUiUrl (URL of the WebUI process). If omitted, they
use the CESIVI_WEBUI_URL environment variable, then fall back to
http://localhost:5510. The WebUI is the canonical store for multilingual
resources today, not the SPM REST server — see the dev doc for the
architectural note.
Read examples¶
# Single resource
Get-CSResource -Key "lists.tasks.title"
# All resources matching a substring
Get-CSResource -KeyContains "lists." | Format-Table Key, Scope, Translations
# Site-collection-scoped resources for one tenant
Get-CSResource -Scope SiteCollection -SiteId 11111111-1111-1111-1111-111111111111
Write examples¶
# Set one culture/value
Set-CSResource -Key "lists.tasks.title" -Culture "de-DE" -Value "Aufgaben"
# Bulk set in one call (Hashtable)
Set-CSResource -Key "lists.tasks.title" -Translations @{
"en-US" = "Tasks"
"de-DE" = "Aufgaben"
"fr-FR" = "Tâches"
}
# Add a French translation while preserving existing en-US/de-DE values
Set-CSResource -Key "lists.tasks.title" -Culture "fr-FR" -Value "Tâches" -Merge
# Site-collection override (overrides whatever System scope says)
Set-CSResource -Key "lists.tasks.title" `
-Scope SiteCollection `
-SiteId 11111111-1111-1111-1111-111111111111 `
-Culture "en-US" -Value "Action Items"
# Delete (asks for confirmation)
Remove-CSResource -Key "lists.tasks.title"
# Delete without prompt
Remove-CSResource -Key "lists.tasks.title" -Confirm:$false
Bulk import from a translation vendor¶
Vendors typically deliver JSON or CSV. Pre-process to the bundle shape and
use Import-CSResources:
# Vendor handed you a CSV of (Key, Culture, Value) triples
$rows = Import-Csv .\vendor-de-DE.csv
$bundle = @{
version = 1
exported = (Get-Date).ToString("o")
scope = "System"
siteId = $null
resources = $rows | Group-Object Key | ForEach-Object {
@{
key = $_.Name
translations = @{}
($_.Group | ForEach-Object { $_.Culture }) | ForEach-Object -Begin {} -Process {} -End {}
# Build the translations map per group
}
}
}
$bundle | ConvertTo-Json -Depth 5 | Set-Content .\import.json
Import-CSResources -Path .\import.json
Import-CSResources -MergeStrategy controls behavior on collision:
Overwrite(default) — replace existing records.Skip— leave existing records untouched, count them as skipped.Fail— abort on the first collision; entries already imported stay.
Round-trip Export → Edit → Import¶
# Export the whole system catalog to disk
Export-CSResources -Path .\system.json
# Hand .\system.json to a translator. They edit translations, save the file.
# When they return it, import. -MergeStrategy Overwrite refreshes everything.
Import-CSResources -Path .\system-translated.json -MergeStrategy Overwrite
The bundle format is documented in
Cesivi.Multilingual/Schemas/multilingual-bundle.v1.json. Importers
tolerate unknown extra fields, so future schema versions can extend
without breaking older imports.
Limitations (v1)¶
- No plural rules. "1 item" vs "2 items" requires application code to pass the count through; the placeholder resolver does not know about plurality.
- No formatter syntax. No
{0:N2}-style number formatting in placeholders. Pass formatted strings in from app code. - No fuzzy match / language tag negotiation. A request for
de-ATwill not match a storedde-DEtranslation. Either store the exact tag requested, or rely on the default-culture fallback. - One placeholder syntax. Only
{{t:key}}is recognized. Other formats (%{key},${key},{0}) are not. - Cache TTL is 60 seconds, but admin edits drain the cache immediately. Edits made via the admin UI or cmdlets are visible on the next render with no measurable lag. Edits applied by other means (direct SQL, file system injection of FileSystem-provider JSON files) require a server restart or a 60-second wait.
Troubleshooting¶
Symptom: Edits don't appear. First check that you saved via the admin
UI or Set-CSResource, not by editing the underlying JSON file directly.
The cache invalidation hook only fires on the API path. If you must edit
the underlying file, restart the WebUI (or wait 60s for the cache TTL).
Symptom: A different value than expected. Remember the 5-step fallback:
SiteCollection-scope override beats System-scope. Use
Get-CSResource -Key X -Scope SiteCollection -SiteId $sid to inspect the
override; if it exists and you didn't expect it, that's why.
Symptom: Bundle import rejected. Run Get-Content .\bundle.json | ConvertFrom-Json
to confirm the JSON parses. Check version: 1 is present at the top
level. The error message names the failing field. Schema validation runs
before any storage write — re-run with -SkipSchemaValidation only if
importing legacy bundles that you know violate v1 in tolerable ways.
Sharing Resources Between Server and WebUI¶
Cesivi runs as two distinct host processes with their own DI containers
and IResourceStore instances:
Cesivi.Server— the SOAP/REST/CSOM backend. Used byIStringLocalizer<T>consumers running server-side (very few in v1). Configured viaCesivi:Multilingual:*inCesivi.Server'sappsettings.json.Cesivi.WebUI— the Razor admin UI + admin API endpoint (/api/multilingual/resources) + page-render-time placeholder resolution for list titles, view names, navigation labels, etc. Configured viaCesivi:Multilingual:*inCesivi.WebUI'sappsettings.json.
By default each process has its own in-memory store. Edits made through
the admin UI in Cesivi.WebUI populate the WebUI's store; they do not
automatically appear in Cesivi.Server's store. For most v1 deployments
this is fine because nearly all resource lookups happen at WebUI render time.
When you must share resources between the two processes¶
Configure both processes with the FileSystem provider pointing at the
same FileSystemRootPath:
// Cesivi.Server/appsettings.json AND Cesivi.WebUI/appsettings.json:
"Cesivi": {
"Multilingual": {
"ProviderType": "FileSystem",
"DefaultCulture": "en-US",
"FileSystemRootPath": "D:\\CesiviShared\\multilingual" // shared path
}
}
Both processes will read and write JSON files in
<FileSystemRootPath>\multilingual\. After an edit, the writing process
flushes immediately; the reading process picks up the new value within
the 60-second resolver-cache TTL (or sooner if the API edit was made on
the same process — that path drains the cache synchronously).
Production warnings¶
- Concurrent writes from both processes are not safe. The FileSystem provider does not coordinate writes across hosts. If admins edit through one process and an automated job edits through the other, last-write-wins with no conflict detection. v1 deployments should pick one writer (typically the WebUI's admin UI / API).
- A dedicated SQL
IResourceStoreprovider is the long-term answer for multi-host deployments. It is on the multilingual roadmap (post-v1) and will replace the shared-FileSystem pattern documented above. - In-memory provider is per-process. Two processes with
ProviderType=InMemorydo not share state. This is the right choice for tests and single-process dev, but never for production multi-host setups.
Migration Waves (PLAN-1312..1346)¶
The Multilingual framework (Phases A–E) shipped the runtime: declarative store,
5-step fallback resolver, {{t:key}} placeholder, Razor @Html.T() helper,
<t/> tag helper, admin UI, PowerShell cmdlets, schema-validated import/export,
auth hardening. Phase F migrated Cesivi's own hardcoded English chrome (page
titles, button labels, validation messages, navigation labels) in 9 waves.
Phase G (PLAN-1346) closed the arc with a cross-wave E2E sweep.
Wave Overview (v1.0 final)¶
| Wave | Surface | Plan | Bundle | Keys (en-US + de-DE) |
|---|---|---|---|---|
| 1 | Admin pages + _AdminLayout chrome |
PLAN-1312 | wave1-admin-en-US.json |
48 |
| 2 | Form pages (NewForm/EditForm/DispForm) + 12 field renderers | PLAN-1315 | wave2-forms-en-US.json |
163 |
| 3 | List-View + Settings + Filter Panel + DispForm residual | PLAN-1317 | wave3-listview-en-US.json |
137 |
| 4 | Calendar + Gantt view chrome | PLAN-1319 | wave4-views-en-US.json |
18 |
| 5 | Shared layouts (_Layout, _ModernLayout, classic-deprecation banner) + auto-discovery refactor |
PLAN-1323 | wave5-layouts-en-US.json |
77 |
| 6 | High-traffic web-part chrome + gallery + picker | PLAN-1326 | wave6-webparts-en-US.json |
174 |
| 7 | Error & Account pages | PLAN-1329 | wave7-errors-en-US.json |
51 |
| 8 | JavaScript-rendered chrome (window.__cesiviI18n + Cesivi.t) |
PLAN-1335 | wave8-js-localization-en-US.json |
115 |
| 8.5 | 29 low-traffic web-parts inline render-chrome | PLAN-1337 | wave8.5-webparts-en-US.json |
384 |
| G | Cross-wave E2E sweep + arc closure | PLAN-1346 | (no new bundle; sanity-sweep only) | — |
| TOTAL | 9 bundles | 14 plans | — | 1167 |
All 9 bundles ship 100% en-US/de-DE parity. BundleSeederHostedService auto-discovers
them via the regex wave(\d+(?:\.\d+)?)- (Wave 5 refactor) — adding a new wave
needs only the JSON file plus an <EmbeddedResource> line in Cesivi.Multilingual.csproj,
no seeder edit. The discovery contract is enforced by
BundleSeederHostedServiceDiscoveryTests (≥ 9 bundles in ascending wave order).
Why waves?¶
A big-bang migration of every .cshtml/.cs would be a 4,000-string change spread
across hundreds of files. Each wave caps blast radius, lets us learn the tooling
on a small surface before scaling, and gives test infrastructure time to catch
regressions one batch at a time.
Wave 1 — Admin pages (PLAN-1312)¶
Surface: Cesivi.WebUI/Pages/Admin/*.cshtml(.cs) — 16 pages + the _AdminLayout
shared layout. Migrated: page titles, descriptions, table column headers, badges,
buttons, form labels + hints, dropdown options, status messages, validation text,
all visible chrome.
Bundle: Cesivi.Multilingual/Data/wave1-admin-en-US.json (49 keys, en-US + de-DE).
Key namespace: admin.* + common.*.
E2E coverage: Cesivi.Tests.WebUI/BrowserTests/MultilingualWaveOneTests.AdminPage_ResolvesGermanChrome.
Wave 2 — Form pages + field-renderer chrome (PLAN-1315)¶
Surface:
- Cesivi.WebUI/Pages/Lists/{NewForm,EditForm,DispForm}.cshtml(.cs) — list-item
create/edit/view forms (page titles, breadcrumbs, section headers, validation
summary, Save/Cancel/Create/Delete buttons, attachment chrome, modal dialogs).
- Action code-behinds: BulkDelete, CreateFolder, DeleteFolder, RenameFolder,
Upload — server-side error/success messages.
- Cesivi.WebUI/FieldRenderers/*.cs — per-field-type chrome (User picker
placeholder; Lookup loading/select hints; Taxonomy "(No terms)" + search +
Browse; Attachment empty/Add/Current; Boolean Yes/No; Calculated empty hint;
BusinessData External badge; URL/Image label+placeholder; Location address
field labels; RichText toolbar tooltips; Text rich-text-allowed hint).
Bundle: Cesivi.Multilingual/Data/wave2-forms-en-US.json (~110 keys, en-US +
de-DE). Key namespaces: forms.{newform,editform,dispform,bulkdelete,upload,
createfolder,deletefolder,renamefolder,confirm}.*, fields.{user,lookup,taxonomy,
attachment,boolean,businessdata,calculated,url,image,location,richtext,text}.*,
shared common.btn.{save,cancel,create,delete,edit,close,confirm},
common.lbl.{home,team_site,error}, common.validation.{summary,required}.
Renderer DI: every renderer that emits chrome takes IMultilingualLocalizer via
constructor. Renderers stay registered as Singleton; the Transient localizer's
deps are all Singleton + lazy-resolved IHttpContextAccessor, so strict-scope
validation stays GREEN. FieldRendererManager is unchanged — DI handles the new
ctor parameter transparently.
E2E coverage: Cesivi.Tests.WebUI/BrowserTests/MultilingualWaveTwoTests:
- FormPage_NewItem_ResolvesGermanChrome — Accept-Language: de-DE resolves
"Neues Element", "Erstellen", "Abbrechen", "Anlagen", "Pflichtfelder".
- FormPage_EditItem_PreservesEnglishWhenAcceptIsEnUs — back-compat sentinel,
ensures default-text fallback keeps en-US chrome unchanged.
Out of scope (deferred to Waves 3-N):
- Settings.cshtml/.cs, AllItems.cshtml/.cs, Export.cshtml/.cs (Wave 3)
- _FilterPanel.cshtml, FilterPanelViewModel.cs (Wave 3)
- _CalendarView.cshtml, _GanttView.cshtml (Wave 4)
- Shared layout — _Layout.cshtml, _ModernLayout.cshtml (Wave 5)
- Web-part chrome (Wave 6)
- Error pages (Wave 7)
- JavaScript user-facing strings inside <script> blocks (TBD JS sub-plan)
Out of scope (deferred to Waves 3-N — historical Wave 1 list — superseded by
the explicit Wave 2 list above):
- Cesivi.WebUI/Pages/Lists/*.cshtml non-form pages — Wave 3
Wave 3 — List-view + Settings + Filter panel + DispForm residual (PLAN-1317)¶
Surface:
- Cesivi.WebUI/Pages/Lists/Settings.cshtml(.cs) — list-settings page (tabs:
general, columns, views, versioning, permissions, advanced, delete) + form
labels, table headers, success/error TempData messages.
- Cesivi.WebUI/Pages/Lists/AllItems.cshtml(.cs) — list-view shell:
not-found error panel, "Back to top", "Try modern view" / "Switch to classic"
link copy, ErrorMessage strings, BadRequest validation, view-title fallbacks
(All Items / All Documents).
- Cesivi.WebUI/Pages/Lists/Export.cshtml.cs — CSV export error path
(List name is required, Export failed: ...).
- Cesivi.WebUI/Pages/Lists/_FilterPanel.cshtml — legacy classic filter-panel
partial (server-side migration only; partial is currently unrendered, retained
for the day it gets resurrected — modern listview's JS-driven filter pane is
out of scope, deferred to a future JS-localization sub-plan).
- Cesivi.WebUI/Pages/Lists/DispForm.cshtml residual sweep — Records-Mgmt
banner (On Hold / Record / Declare / Undeclare), Workflow status banner,
Multi-stage approval pipeline, Publishing scheduled/expired banner, Publishing
Page info panel, Sharing-info panel headers, Hide-empty + Print toolbar
buttons, File-row label, Attachment placeholder, Discussion replies count +
empty state, Like button, Workflow-history modal headers + columns + Close.
Bundle: Cesivi.Multilingual/Data/wave3-listview-en-US.json (137 keys, en-US +
de-DE). Key namespaces:
- settings.* — header.title, breadcrumb.aria, section.{general,columns,
views,versioning,permissions,advanced,delete}.title, per-section form labels +
buttons + status messages.
- allitems.* — viewtitle.{all_items,all_documents}, error.{list_not_found,
list_not_found_msg,go_home,list_required,load_failed}, scroll.back_to_top,
lookpicker.{switch_classic,try_modern}, fallback.list.
- filterpanel.* — title, field.select_placeholder, value.placeholder,
btn.{add,apply,clear,remove}, op.{equals,not_equals,contains,begins_with,
greater_than,greater_or_equal,less_than,less_or_equal,is_null,is_not_null}.
- export.* — error.list_required, error.failed.
- dispform.* — extension of Wave-2 forms.dispform.* namespace with
banner.{records_management,workflow_status,pipeline_stage,publishing}.*,
publishing.{title,lbl.page_layout,lbl.start_date,lbl.expiration},
sharing.{title,links_title}, btn.{hide_empty,hide_empty_tooltip,print,
print_tooltip}, lbl.file, attachment.btn.remove, discussion.{replies_count,
btn.reply,empty}, like.{btn,btn_tooltip,count_tooltip},
workflow_history.{help,col.workflow,col.started,col.status,col.user_status},
lookpicker.try_modern, print.{item_label,printed}.
- shared common.bool.{yes,no}, common.lbl.loading (in addition to Wave-2
shared keys).
DI: SettingsModel, AllItemsModel, ExportModel constructors take
IMultilingualLocalizer via DI. _FilterPanel.cshtml uses @Html.T() directly
(no ViewModel DI change — the ViewModel stays a pure DTO). DI strict-scope test
MultilingualResourceStoreDIRegistrationTests.AddCesiviMultilingual_ValidateScopes_FactoryAndLocalizerResolveCleanly
stays GREEN — Wave 3 introduces no new singletons that would consume scoped
services.
E2E coverage: Cesivi.Tests.WebUI/BrowserTests/MultilingualWaveThreeTests:
- AllItemsPage_ResolvesGermanChrome — Accept-Language: de-DE resolves
"Nach oben" (back-to-top tooltip).
- SettingsPage_ResolvesGermanChrome — section labels render in German:
"Allgemeine Einstellungen", "Spalten", "Ansichten", "Berechtigungen".
- ExportErrorPath_ResolvesGermanChrome — /Lists/%20/Export.aspx BadRequest
body resolves to "Listenname ist erforderlich" (with fallback to AllItems
not-found-panel chrome "Liste nicht gefunden" / "Zur Startseite").
BundleSeederHostedService now seeds 3 bundles at startup: wave1-admin-en-US,
wave2-forms-en-US, wave3-listview-en-US. The seeder is idempotent — admin
overrides in the configured IResourceStore are preserved.
Wave-3 verifying scan command:
dotnet run --project tools/Cesivi.Multilingual.ScanTool -- \
--target Cesivi.WebUI/Pages/Lists \
--repo-root . \
--out _project/areas/multilingual/WAVE-3-VERIFY.json
Per Wave-2 lessons (plan-feedback-1315.md), residual MIGRATE counts are
noise-dominated (HTML attributes, format tokens, internal field names);
acceptance is the 3/3 E2E German chrome assertion, not residual MIGRATE.
Out of scope (deferred to Waves 4-N):
- _CalendarView.cshtml, _GanttView.cshtml (Wave 4 — specialised view partials)
- Shared layout chrome (Wave 5)
- Web-part chrome (Wave 6)
- Error pages (Wave 7)
- JavaScript user-facing strings inside <script> blocks (TBD JS sub-plan)
- _FilterPanel.cshtml JS-driven client template (deferred to JS sub-plan)
Wave 4 — Calendar + Gantt view chrome (PLAN-1319)¶
Surface:
- Cesivi.WebUI/Pages/Lists/_CalendarView.cshtml — month-grid calendar partial
(146 lines). Server-side static chrome: prev/today/next month buttons, +New
Event link, the initial loading-state title, the initial 0-events badge.
- Cesivi.WebUI/Pages/Lists/_GanttView.cshtml — Gantt timeline partial (590
lines). Server-side static chrome: loading spinner text, time-scale label,
Day/Week/Month + Today buttons (text + title attrs), task-table column
headers (Task / Start / End).
Wave 4 closes the Pages/Lists/ Razor chrome surface entirely. Subsequent
waves migrate out of Pages/Lists/ (shared layouts → web-parts → error
pages → JS strings).
Bundle: Cesivi.Multilingual/Data/wave4-views-en-US.json (18 keys, en-US +
de-DE). Key namespaces:
- calendar.btn.{prev_month, today, next_month, new_event} — calendar nav.
- calendar.label.event_count_zero — initial event-count badge (JS overwrites
on data load; server emits the en-US/de-DE start state).
- gantt.lbl.{loading, time_scale} — Gantt loader + zoom-toolbar label.
- gantt.btn.{day, week, month, today, day_view, week_view, month_view,
scroll_to_today} — toolbar zoom buttons (text + title attrs).
- gantt.column.{task, start, end} — left-panel task-table column headers.
JS-side strings (calendar monthNames / dayAbbr / (No title) placeholder /
event-plural suffix, Gantt tooltip labels / W{n} week markers / Item N
fallback / toLocaleDateString('en-US') formatters / "Failed to load Gantt
data" error / "No items with valid start date found" empty state) are
DEFERRED to a future JS-localization sub-plan — Wave 4 covers server-side
Razor chrome only.
DI: no view-models exist for the partials (both bind directly to
AllItemsModel); migration uses @Html.T() calls in Razor with no DI
changes. MultilingualResourceStoreDIRegistrationTests.AddCesiviMultilingual_ValidateScopes_FactoryAndLocalizerResolveCleanly
passes — Wave 4 introduces no new singletons that would consume scoped
services.
E2E coverage: Cesivi.Tests.WebUI/BrowserTests/MultilingualWaveFourTests:
- CalendarPage_ResolvesGermanChrome — creates a CALENDAR view via REST,
GETs /Lists/Tasks/AllItems.aspx?view={name} with Accept-Language: de-DE,
asserts response body contains Heute + Neuer Termin.
- GanttPage_ResolvesGermanChrome — creates a GANTT view via REST,
GETs the same URL pattern, asserts response body contains Aufgabe +
Beginn + Ende + Heute.
Both tests use direct HTTP GET (APIRequest.GetAsync) instead of Playwright
page-rendering because the existing browser-based Calendar/Gantt tests have a
pre-existing visibility/timing issue with #spCalendarContainer /
#spGanttContainer selectors that's unrelated to chrome migration. The
server-side Razor render output is what Wave 4 actually changed and what we
assert on.
BundleSeederHostedService now seeds 4 bundles at startup: wave1-admin-en-US,
wave2-forms-en-US, wave3-listview-en-US, wave4-views-en-US. Idempotent —
admin overrides are preserved.
Wave-4 verifying-scan command:
dotnet run --project tools/Cesivi.Multilingual.ScanTool -- \
--target Cesivi.WebUI/Pages/Lists/_CalendarView.cshtml \
--target Cesivi.WebUI/Pages/Lists/_GanttView.cshtml \
--repo-root . \
--out _project/areas/multilingual/WAVE-4-VERIFY.json
Wave 5 — Shared layout chrome (PLAN-1323)¶
Surface:
- Cesivi.WebUI/Pages/Shared/_Layout.cshtml — classic-UI page shell (563 lines)
rendered for every ?ui=classic request. Migrated chrome: ribbon-toggle bar,
site-title fallback, follow-this-site button (initial server-rendered text +
title), edit-mode toolbar, closed-site banner template, the keyboard-shortcut
help modal (~50 strings — section headers, group headers and shortcut
descriptions), and the scroll-to-top button.
- Cesivi.WebUI/Pages/Shared/_ModernLayout.cshtml — modern-UI page shell (209
lines) reached by every default request (modern is the default). Migrated:
the spm-editmode-bar notification (aria-label + label + Save button text +
title + Discard button text + title).
- Cesivi.WebUI/Pages/Shared/_ClassicDeprecationBanner.cshtml (41 lines) —
rendered into _Layout when IModernUiOptIn.IsExplicitClassic is true.
Migrated: aria-label, banner text, switch button, dismiss button title.
Wave 5 closes the Pages/Shared/_*.cshtml layout-chrome surface for both UIs.
Skipped (chrome-free → no migration needed): _AuthLayout.cshtml,
_ErrorLayout.cshtml, _LayoutBlank.cshtml — only chrome they emit is the
dynamic <title>@ViewData["Title"]</title> plus the brand link "Cesivi
Server", which falls under the brand-string exception (see below).
Bundle: Cesivi.Multilingual/Data/wave5-layouts-en-US.json (77 keys, en-US +
de-DE). Key namespaces:
- layouts.modern.editmode_* — modern edit-mode banner (6 keys).
- layouts.classic_banner.* — deprecation banner (4 keys).
- layouts.classic.{ribbon_toggle, site_title_fallback, follow_*, editmode_*,
closed_banner_*, scroll_top_aria} — classic layout chrome (~10 keys).
- layouts.classic.kb_* — keyboard-help modal (~57 keys: title, close aria,
4 section headers, 8 group headers, ~43 shortcut descriptions and their
action labels).
Brand-string exception: the literal Cesivi (in <title>… - Cesivi</title>
on every layout, plus the "Cesivi Server" anchor in _AuthLayout/_ErrorLayout)
is never translated — it is the product brand name. Translatable content is
the surrounding chrome only.
Attribute-encoding note: @Html.T(key, "default") returns an
HtmlString (already HTML-encoded). When used in an HTML attribute, write it
as title="@Html.T("…","…")" — NOT title="@Html.T("…","…").ToString()".
The .ToString() form double-encodes non-ASCII characters (umlauts → &#252;
literally visible to users on hover). Wave 5 enforces the no-.ToString()
pattern in the Wave 5 layouts; existing pages that use .ToString() for
ASCII-only strings (e.g. AllItems.cshtml) are unaffected because there are no
special characters to double-encode.
Auto-discovery refactor: BundleSeederHostedService no longer hard-codes a
bundle list. Instead it discovers all embedded resources matching
Cesivi.Multilingual.Data.wave{N}-*-en-US.json via reflection and seeds them
in ascending wave-number order. New waves drop a JSON file next to the
existing ones + register an <EmbeddedResource> in Cesivi.Multilingual.csproj
— no edit required to the seeder. A regression unit test
(BundleSeederHostedServiceDiscoveryTests) asserts that ≥ 5 bundles are
discovered and that the canonical Wave 1–5 names are present in ascending
wave order.
DI: no new view-models or singletons; the layouts use @Html.T() calls
directly. MultilingualResourceStoreDIRegistrationTests.AddCesiviMultilingual_ValidateScopes_FactoryAndLocalizerResolveCleanly
stays GREEN.
E2E coverage: Cesivi.Tests.WebUI/BrowserTests/MultilingualWaveFiveTests:
- LayoutChrome_ResolvesGermanChrome — direct GET
/Lists/Tasks/AllItems.aspx?ui=classic with Accept-Language: de-DE;
HTML-decode the response body; assert the keyboard-help modal title
(Tastaturkürzel), the Clipboard & Fill group header (Zwischenablage),
the scroll-top aria-label (Nach oben scrollen) and the closed-site
banner link (Schließungsdetails).
- ModernLayoutChrome_ResolvesGermanChrome — direct GET
/Lists/Tasks/AllItems.aspx (modern UI is the default); assert the
edit-mode banner emits Seite wird bearbeitet, Speichern and Verwerfen.
- ClassicDeprecationBanner_ResolvesGermanChrome — direct GET with
?ui=classic; assert klassische and modernen (German banner text and
switch-button label).
Tests use the Wave-4 plan-feedback recommendation pattern: direct HTTP GET
via APIRequest.GetAsync rather than Playwright page rendering, because
server-side Razor output is exactly what Wave 5 changed.
JS-rendered runtime chrome (toolbar toggle labels manipulated by
spToggleRibbon(), follow-button toast text, edit-mode JS-set strings) is
DEFERRED to Wave 7 (JS-localization sub-plan).
Wave 6 — Web-part chrome (PLAN-1326)¶
Surface:
- Cesivi.WebUI/WebParts/BuiltIn/*.cs — 36 built-in web-parts. Migrated
chrome: Title / Description (gallery + picker dialog), empty-state
messages, error chrome inside RenderAsync, all property-editor form
labels emitted by RenderEditPropertiesAsync for the high-traffic web
parts (ContentEditor, IFrame, Callout, KpiTile, Announcements, GettingStarted).
AutoCreatedListWebPart migrates its dynamic Description ("Displays items
from the '{0}' library/list") plus the column-header rewrites
(FileLeafRef → Name, Editor → Modified By).
- Cesivi.WebUI/WebParts/WebPartManager.cs — render-error chrome:
"Web part not found", "Error rendering web part…", "Error rendering
property editor…". Emits localized strings via the new optional
IMultilingualLocalizer constructor parameter.
- Cesivi.WebUI/Pages/Shared/_WebPartGalleryModal.cshtml — picker dialog
modal: "Add a Web Part", "Categories", "All", "Search web parts...",
"Loading web parts...", "Upload a Web Part", import description, "Upload",
"Cancel", "Close".
- Cesivi.WebUI/Pages/_layouts/15/WebPartGallery.cshtml(.cs) — gallery
page chrome: page title, breadcrumb, info banner, section titles,
table column headers ("Web Part", "Internal Name", "Category",
"Description", "List / Library", "Type"), dynamic type labels
("Document Library", "Calendar", "Discussion Board", "Tasks",
"Announcements", "Custom List"), footer copy, "Back to Site Settings".
- Cesivi.WebUI/Pages/Api/WebPartOps.cshtml.cs — picker dialog API
(/api/webpartops?handler=AvailableWebParts) now returns localized
title, description and categoryLabel per entry.
Wave 6 closes the Cesivi.WebUI/WebParts/ chrome surface plus the picker
modal + gallery page.
Skipped: WebPartBase.cs, IWebPart.cs, WebPartContext.cs,
WebPartCategory.cs, WebPartAdapter.cs, WebPartRegistry.cs,
CspWebPartCatalog.cs, TemplateWebPart.cs — emit no user-facing chrome.
Web-part property defaults that are user-editable seed values (e.g.
CalloutWebPart's "Heads up" placeholder) are NOT migrated — they are
data the user replaces, not chrome.
Bundle: Cesivi.Multilingual/Data/wave6-webparts-en-US.json (174 keys,
en-US + de-DE). Key namespaces:
- webparts.framework.* — manager error chrome (3 keys).
- webparts.category.* — WebPartCategory enum → label mapping (9 keys).
- webparts.gallery.modal.* — picker dialog modal chrome (10 keys).
- webparts.gallery.page.* — gallery page chrome (14 keys).
- webparts.gallery.types.* — dynamic auto-list type labels (6 keys).
- webparts.<webpartName>.title / .description — 36 × 2 keys.
- webparts.<webpartName>.{empty,error,error_*,lbl_*,hint_*,style_*,scrolling_*,...}
— per-web-part render chrome (~60 keys, concentrated in high-traffic web parts).
- webparts.autoList.* — dynamic-list helpers (12 keys).
DI: web parts opt in to localization via an OPTIONAL trailing
IMultilingualLocalizer? localizer = null constructor parameter — this
preserves the parameterless-construction contract that
WebPartRegistry.DiscoverWebParts() relies on at startup
(Activator.CreateInstance(type) for harvesting WebPartType).
Per-render instances created via ActivatorUtilities.CreateInstance get
the localizer injected. WebPartManager itself takes the localizer the
same way (optional ctor param), and forwards it into
AutoCreatedListWebPart constructor calls. Strict-scope DI test
(MultilingualResourceStoreDIRegistrationTests) stays GREEN — no new
singleton/scoped lifetime conflicts introduced.
Identity vs. display: ICesiviWebPart.Title / Description remain
stable English strings (used as identity for GetWebPartByTitle()
lookups and as OrderBy() keys). New default-implementation interface
properties TitleKey / DescriptionKey (default empty) opt a web-part
into chrome localization. Display-path callers (gallery page, picker
API, error chrome in WebPartManager) use the
CesiviWebPartLocalizationExtensions.LocalizedTitle/LocalizedDescription/LocalizedLabel
extensions. This split avoids breaking existing string-based lookups
when the user culture is non-en-US.
E2E coverage: Cesivi.Tests.WebUI/BrowserTests/MultilingualWaveSixTests:
- WebPartGalleryPage_ResolvesGermanChrome — direct GET
/_layouts/15/WebPartGallery.aspx with Accept-Language: de-DE;
HtmlDecode the body; assert "Webpartkatalog", "Websiteeinstellungen",
"Integrierte Webparts", "Interner Name", "Kategorie", "Zurück zu den
Websiteeinstellungen", "Inhalts-Editor", "KPI-Kachel", "Diagramme".
- WebPartPickerApi_ResolvesGermanWebPartChrome — direct GET
/api/webpartops?handler=AvailableWebParts&webUrl=/; assert
"Inhalts-Editor", "Einzelner Messwert", "Diagramme",
"categoryLabel":"Inhalt" in the JSON body.
- WebPartGalleryModal_ResolvesGermanChrome — direct GET
/SitePages/Home.aspx?editmode=1 (renders the modal partial when
the page hosts web-part zones); assert "Webpart hinzufügen",
"Kategorien", "Webpart hochladen". Test is permissive: skips if the
modal partial isn't included on the test page (gallery page test
already covers the chrome surface).
Bundle-discovery regression test
(BundleSeederHostedServiceDiscoveryTests) bumped to assert ≥ 6 bundles
discovered, with wave6-webparts-en-US.json named explicitly and
ordered after wave5.
Wave 7 — Error & account pages (PLAN-1329)¶
Surface:
- Cesivi.WebUI/Pages/Error.cshtml(.cs) — server-error page (rendered
by both UseExceptionHandler("/Error") and
UseStatusCodePagesWithReExecute("/Error", "?statusCode={0}")).
All status codes (404, 403, 500, fallback) render through this single
page; per-status-code title and message are localized in the
code-behind via IMultilingualLocalizer (constructor-injected).
Razor body uses @Html.T().
- Cesivi.WebUI/Pages/Account/AccessDenied.cshtml — 403 / role-denied
page reached via CookieAuthenticationOptions.AccessDeniedPath. All
visible chrome (card title, lead body, hint, both buttons) localized
via @Html.T(). Empty OnGet left as-is.
- Cesivi.WebUI/Pages/Account/Login.cshtml(.cs) — sign-in form. Form
labels, button text, demo-mode notices, configuration-required
warnings, divider, footer hints, placeholder text — all localized.
Server-side ErrorMessage strings ("Username is required.", "No
authentication provider configured.", "Provider '{0}' is not
available.") localized in the code-behind via IMultilingualLocalizer.
- Cesivi.WebUI/Pages/Account/Logout.cshtml — logout-confirmation
prompt. Header, prompt sentence, both action buttons localized.
- Cesivi.WebUI/Pages/Account/External.cshtml(.cs) — OIDC/SAML/Windows
callback handler. Spinner label, "Completing Sign In..." processing
card, "Authentication Failed" + "Return to Login" failure card all
localized; server-side ErrorMessage assignments
($"Authentication failed: {remoteError}", "Authentication was not
completed. Please try again.", "Authentication processing failed.")
localized in the code-behind.
Out of scope (verified during Phase 0 scan, filed at
_project/areas/multilingual/PLAN-1329-WAVE-7-SCAN.md):
- Pages/Shared/_ErrorLayout.cshtml — chrome-free apart from
<title>@ViewData["Title"] - Cesivi Server</title>. The
ViewData["Title"] is page-supplied (and localized through the page
bundle); the brand suffix "Cesivi Server" is the standing brand
exception. SKIP-CHROME-FREE verdict reaffirmed.
- Per-status-code Razor pages — none exist; all status codes go through
/Error. Wave 7 covers the entire status-code surface.
- Pages/Account/AccessDenied.cshtml.cs and
Pages/Account/Logout.cshtml.cs code-behinds — empty OnGet(); no
user-facing strings. SKIP-CHROME-FREE.
Bundle: Cesivi.Multilingual/Data/wave7-errors-en-US.json (49 keys,
en-US + de-DE). Key namespaces:
- errors.error_page.* — Error.cshtml(.cs) chrome including 404/403
branches (11 keys).
- errors.access_denied.* — AccessDenied.cshtml (6 keys).
- account.login.* — Login.cshtml(.cs) form chrome + server error
messages (20 keys).
- account.logout.* — Logout.cshtml (5 keys).
- account.external.* — External.cshtml(.cs) (9 keys).
Razor @inject IMultilingualLocalizer L is used in AccessDenied,
Login, Logout, External cshtml files to set
ViewData["Title"] = L["...", "..."].Value (plain string → Razor's
auto-encoding handles it correctly, no double-encoding). Body chrome
uses @Html.T() (returns HtmlString). For inline HTML fragments
that contain markup (e.g. <code>appsettings.json</code> hint), the
pattern is @Html.Raw(L["...", "..."].Value) — the raw HTML is
trusted because both the en-US default and de-DE translation are
project-controlled.
E2E coverage: Cesivi.Tests.WebUI/BrowserTests/MultilingualWaveSevenTests:
- ErrorPage_ResolvesGermanChrome — direct GET /Error with
Accept-Language: de-DE; assert "Fehler aufgetreten",
"Entwicklerteam wurde benachrichtigt", "Zur Startseite", "Zurück".
- ErrorPage_404_ResolvesGermanChrome — direct GET
/Error?statusCode=404; assert "Nicht gefunden", "existiert nicht
oder wurde verschoben".
- ErrorPage_403_ResolvesGermanChrome — direct GET
/Error?statusCode=403; assert "Zugriff verweigert", "keine
Berechtigung".
- AccessDeniedPage_ResolvesGermanChrome — direct GET
/Account/AccessDenied; assert "Zugriff verweigert", "keine
Berechtigung", "Administrator", "Zur Startseite", "Abmelden".
- LoginPage_ResolvesGermanChrome — direct GET /Account/Login;
assert "Anmelden", "Benutzername", "Kennwort", "angemeldet bleiben",
"Demo-Modus".
- LogoutPage_ResolvesGermanChrome — login as admin first, then GET
/Account/Logout; assert "Abmelden", "wirklich abmelden", "Ja,
abmelden", "Abbrechen".
- ExternalPage_ResolvesGermanChrome — anonymous GET
/Account/External?remoteError=test_error (no auth → ErrorMessage
branch); assert "Anmeldung fehlgeschlagen", "Zurück zur Anmeldung".
Bundle-discovery regression test
(BundleSeederHostedServiceDiscoveryTests) bumped to assert ≥ 7 bundles
discovered, with wave7-errors-en-US.json named explicitly and ordered
after wave6.
Wave 7 closes the server-side Razor surface for the error and account flows.
Wave 8 — JavaScript localisation (PLAN-1335)¶
Wave 8 closes the JavaScript-rendered chrome that no @Html.T(...) call
can reach: Calendar JS labels (month/day names, plural event count), Gantt
JS labels (tooltip Start/End/Duration/Complete/Assigned/Overdue, week
marker, no-data + load-failed states), ContentEditor Quill toolbar
tooltips (Bold/Italic/Underline/Header/Link/Clear-formatting), and the
modern listview chrome surface (column-menu items, row-menu items, sort
indicators, grid toolbar buttons, save-view-as dialog, export toasts,
inline-edit failures, aggregate footer labels, details pane). Bundle
file wave8-js-localization-en-US.json ships ~108 keys with full en-US
+ de-DE translations; BundleSeederHostedService auto-discovers it
(zero seeder edit per Wave-5 refactor).
The JS layer obtains the bundle via a new server-side embed mechanism
(BundleEmbeddingHelper.EmbedI18nBundle()) that emits, right after the
opening <body> tag, a <script>window.__cesiviI18n = { "key": "value",
... };</script> payload reflecting the current culture's resolution
through the 5-step layered resolver. The cesivi-i18n.js helper module
defines Cesivi.t(key, defaultText, args), Cesivi.tHtml(...), and
Cesivi.tResolvePlaceholders(text) — each mirrors the corresponding
server-side semantic. v1.0 ships with no extra HTTP round-trip; the
bundle is embedded inline, gzipped to ~6–18 KB per page.
Cesivi.t API for customer-side developers¶
// Simple lookup with English fallback.
var label = Cesivi.t('webparts.contentEditor.quill.bold', 'Bold');
// Mustache-style argument interpolation.
var msg = Cesivi.t('listview.modern.export.copy_count',
'Copied {count} rows to clipboard',
{ count: 42 });
// Positional argument interpolation.
var greeting = Cesivi.t('cesivi.greeting',
'Hello {0}, you have {1} messages.',
['Ada', 5]);
// HTML-encoding of args (body markup left intact).
var html = Cesivi.tHtml('cesivi.welcome',
'Welcome <strong>{{name}}</strong>!',
{ name: userName /* may contain hostile HTML */ });
// {{t:key}} placeholder resolution.
var resolved = Cesivi.tResolvePlaceholders('Press {{t:listview.modern.btn.save}} to continue.');
The lookup chain is window.__cesiviI18n[key] → defaultText → key
literal. A missing bundle never throws; the helper degrades gracefully
to defaultText (or the key name on no default).
Wave 8 v1.x BACKLOG hooks (item #17)¶
Locked design decisions in _project/areas/multilingual/JS-LOC-DESIGN.md
document the v1.x extension points that v1.0 reserves without breaking
the contract:
- Q1=C — Hybrid loader (embed CRITICAL ~50 keys, lazy-fetch the long
tail). Adds
Cesivi.tAsync(key, defaultText, args)sibling that falls back toCesivi.tfor already-embedded keys. The embed mechanism becomes a critical-keys subset. - Q2=B — SignalR live invalidation.
MultilingualResourceChangedNotifierfires on resolver invalidation;cesivi-i18n.jssubscribes to amultilingual-resource:<culture>group and updates__cesiviI18n[key]in place. Emitscesivi-i18n:changedDOM event ondocumentfor any consumer that wants live re-render. - Q3=B — DOM-walker mid-session culture switch. Server-rendered
<t/>tag-helper output gainsdata-i18n-keyattributes;Cesivi.switchCulture(newCulture)re-fetches the bundle and walks[data-i18n-key]elements to update text/attributes. Wave 8 emit-side may already writedata-i18n-keyattributes so v1.x DOM-walker has zero retrofit cost (optional, not gated).
Remaining Wave 8.5 work: ~~30 low-traffic web-parts' inline render-chrome~~,
plus the ml-addcolumn / ml-columns / ml-filterpane / ml-group
JS surfaces that overlap with server-side forms.fields.* keys.
Wave 8.5 — low-traffic web-parts inline render-chrome (PLAN-1337)¶
Wave 8.5 closes the inline render-chrome surface for the 29 low-traffic
built-in web-parts that Wave 6 left with only TitleKey/DescriptionKey
wired (so the picker dialog and gallery were already localised, but the
property-editor form labels, status messages, error chrome, and JS-side
dynamic strings remained English).
Coverage (29 parts, 268 keys total):
- Trivial (≤8 keys): Divider, Heading, QuickLinks, SiteActivity, ContactDetails, FilterWebPart, ScriptEditor.
- Medium (8–14 keys): PageViewer, Image, Media, HtmlForm, PromotedLinks, RelevantDocuments, SiteUsers, ListView, XmlViewer, RSSFeed, SummaryLinks, TableOfContents, ProjectSummary, WhatsNew, CommunityDiscussion, UserTasks.
- Mid-heavy (~21–25 keys each): Chart, Gallery, Table, ContentQuery.
- Heaviest (~29–32 keys): Calendar (12 month names + 7 day-of-week headers + 3 view-mode buttons + 5 modal labels + features list), StatusIndicator (6 aggregation options + 3 display modes + 3 status labels routed through
GetStatus(...)signature change).
Pattern (per-part):
public class FooWebPart : ICesiviWebPart
{
private readonly IMultilingualLocalizer? _localizer;
public FooWebPart(IFooClient fooClient, IMultilingualLocalizer? localizer = null)
{
_fooClient = fooClient;
_localizer = localizer; // optional trailing parameter — Wave 6 lesson
}
private string T(string key, string defaultText)
=> _localizer is null ? defaultText : _localizer[key, defaultText].Value;
public Task<IHtmlContent> RenderEditPropertiesAsync(WebPartContext context)
{
var lblFoo = T("webparts.foo.lbl_foo", "Foo Label");
// …
}
}
JS-side localised strings (Chart, ContentQuery, Gallery, StatusIndicator,
Table, Calendar editors): inline JS source like '— Loading lists... —'
cannot be HtmlEncode'd (breaks JS escape rules) — must be JS-string-literal-
encoded via System.Text.Json.JsonSerializer.Serialize(localisedString) in
C# and emitted as a JS variable that the inline JS references.
Bundle: Cesivi.Multilingual/Data/wave8.5-webparts-en-US.json (268 keys,
en-US + de-DE, embedded resource auto-discovered by BundleSeederHostedService
which sorts decimally so wave8.5 falls between wave8 and wave9).
Test coverage: 2 RED→GREEN E2E tests (MultilingualWaveEightPointFiveTests
in Cesivi.Tests.WebUI/BrowserTests/) for PageViewer + StatusIndicator
representative property-editor German chrome resolution. Bundle-discovery
test bumped to ≥ 9 (Waves 1–8 + Wave 8.5).
Result: Cesivi.WebUI/WebParts/ chrome surface CLOSED entirely (Wave 6
+ Wave 8.5). Phase G arc-closure becomes eligible.
How to run the scan tool¶
The scan tool finds hardcoded user-facing strings in a directory and classifies
them MIGRATE / KEEP / DEFER per the locked allow-list at
_project/areas/multilingual/SCAN-ALLOWLIST.md.
# Scan a directory and emit JSON to stdout
dotnet run --project tools/Cesivi.Multilingual.ScanTool -- --target Cesivi.WebUI/Pages/Lists
# Save report to a file (recommended for diffing pre-vs-post-migration scans)
dotnet run --project tools/Cesivi.Multilingual.ScanTool \
-- --target Cesivi.WebUI/Pages/Lists \
--repo-root . \
--out _project/areas/multilingual/WAVE-2-SCAN.json
The output is a JSON document where each MIGRATE finding includes a
suggested_key and a suggested_replacement. The implementer is free to
override either during Phase 3 of the wave.
How to add translations to a wave's bundle¶
Each wave ships a baseline bundle in Cesivi.Multilingual/Data/wave<N>-<surface>-en-US.json.
The bundle:
- Carries the English default for every key in the wave (matches the
default-text fallback baked into
@Html.T(key, "English")). - Carries a
de-DEGerman translation for any key that has a localized value. Missing translations fall through to the en-US default. - Is loaded into the configured
IResourceStoreat startup byBundleSeederHostedService(registered automatically byservices.AddCesiviMultilingual(...)). Existing keys in the store are preserved — admin overrides win.
To add or correct a translation:
- Open
Cesivi.Multilingual/Data/wave1-admin-en-US.json. - Find the entry whose
keymatches the resource you want to translate. - Add a culture-code property under
translations, e.g."fr-FR": "Filtrer". - Restart the WebUI. The seeder runs once at startup and inserts only missing keys. To force a re-seed of an existing key, delete it from the admin UI first, then restart.
For a one-shot bulk import without restarting, use the PowerShell cmdlet:
Import-CSResources -Path .\my-french-overlay.json -WebUiUrl http://localhost:5510
Key-naming convention (LOCKED at Phase 0)¶
<surface>.<page>.<element>[.<modifier>]
<surface>—admin,forms,layouts,webparts,lists,home, etc.<page>— Razor page filename (lowercased), e.g.multilingualforMultilingual.cshtml.<element>— semantic role:title,subtitle,description,info,text.<slug>,lbl.<slug>,btn.<slug>,placeholder.<slug>,tooltip.<slug>,validation.<slug>,column.<slug>,option.<slug>,badge.<slug>,flash.<slug>,link.<slug>,nav.<slug>,section.<slug>.<modifier>— optional contextual variant:inherited,override,pending,error,ok.
Reusable cross-page strings live under common.:
common.btn.{save,cancel,delete,edit,new,close,filter,reset,refresh,create, enable,disable,manage,remove,previous,next,ok,yes,no}common.lbl.{name,description,status,actions,modified,created}common.status.{enabled,disabled}common.validation.{required,...}common.{loading,yes,no}
Full convention reference: _project/areas/multilingual/SCAN-ALLOWLIST.md.
Default-text fallback discipline¶
Every migrated string keeps its English fallback as the second argument:
<h2 class="admin-page-title">@Html.T("admin.multilingual.title", "Multilingual Resources")</h2>
If no translation bundle exists for the requested culture, the resolver returns
the key literal — @Html.T(...) then substitutes the supplied default text.
This guarantees zero regression when migrations land before translations are
authored.
In code-behind, inject IMultilingualLocalizer (NOT IStringLocalizer<TPage>,
which prefixes keys with typeof(T).FullName):
public AuthProvidersModel(..., IMultilingualLocalizer localizer) { _localizer = localizer; }
// ...
Description = _localizer["admin.authproviders.ntlm.description",
"Windows Integrated Authentication using NTLM protocol"].Value;
JavaScript Helpers (Wave 8, v1.0)¶
Customer-side developers writing JavaScript in wwwroot/js/ (or in inline
<script> blocks emitted by Razor) localise strings via the Cesivi global,
populated by a server-side embed (window.__cesiviI18n) injected into every
page right after the opening <body> tag.
The lookup chain is window.__cesiviI18n[key] → defaultText → key literal.
A missing bundle never throws; the helper degrades gracefully to defaultText
(or the key name when no default is supplied).
| Helper | Purpose | Encoding |
|---|---|---|
Cesivi.t(key, defaultText, args?) |
Plain-text lookup with mustache or positional argument interpolation | Returns plain string (caller must HTML-encode if injected via innerHTML) |
Cesivi.tHtml(key, defaultText, args?) |
HTML-aware lookup that HTML-encodes argument values but leaves the body's HTML markup intact | Body is treated as trusted HTML; arg values are encoded |
Cesivi.tResolvePlaceholders(text) |
Resolves {{t:key}} placeholders in arbitrary text (mirrors the server-side RenderTitleAsync semantics) |
Plain text |
Usage¶
// Simple lookup with English fallback.
var label = Cesivi.t('webparts.contentEditor.quill.bold', 'Bold');
// Mustache-style argument interpolation.
var msg = Cesivi.t('listview.modern.export.copy_count',
'Copied {count} rows to clipboard',
{ count: 42 });
// Positional argument interpolation.
var greeting = Cesivi.t('cesivi.greeting',
'Hello {0}, you have {1} messages.',
['Ada', 5]);
// HTML-encoding of arg values (body markup left intact).
var html = Cesivi.tHtml('cesivi.welcome',
'Welcome <strong>{{name}}</strong>!',
{ name: userName /* may contain hostile HTML */ });
// {{t:key}} placeholder resolution in user-supplied / dynamic text.
var resolved = Cesivi.tResolvePlaceholders(
'Press {{t:listview.modern.btn.save}} to continue.');
Embed mechanism¶
The embed is generated by BundleEmbeddingHelper.EmbedI18nBundle() (see
MULTILINGUAL-RESOURCE-STORE.md)
and reflects the request's effective culture after the 5-step layered
resolver fallback. The <script> element uses JavaScriptEncoder.Default so
hostile content like </script> cannot break out. Cache TTL is 60 seconds;
admin edits drain the cache synchronously, so the next request sees the new
value.
For deeper architecture (embed placement, performance, CSP-nonce compatibility), see the dev guide.
v1.x Roadmap (extension hooks designed, not v1-blocking)¶
The v1.0 multilingual arc closed with the following extension hooks designed
during PLAN-1335 (Wave 8) but deliberately not shipped. They are recorded
in BACKLOG.md item #17 as v1.x sub-bullets and will land if customer demand
surfaces. None of these blocks v1.0.
| ID | Title | Trigger to revisit |
|---|---|---|
| Q1=C | Hybrid loader — embed CRITICAL ~50 keys, lazy-fetch the long tail via REST. Adds Cesivi.tAsync(key, defaultText, args) sibling. |
Page-load latency budget on the largest bundles is exceeded by the inline embed — gzipped wire size today is ~6–18 KB and well under target. |
| Q2=B | SignalR live invalidation — MultilingualResourceChangedNotifier fires on resolver invalidation; cesivi-i18n.js subscribes to a multilingual-resource:<culture> group and updates __cesiviI18n[key] in place. |
A workflow emerges where admins edit translations multiple times per session and require open-tab tabs to update without reload. |
| Q3=B | DOM-walker mid-session culture switch — Cesivi.switchCulture(newCulture) re-fetches the bundle and walks [data-i18n-key] elements to update text/attributes without page reload. |
A localised UI workflow needs the user to switch cultures mid-session (e.g. multilingual demo / training site). |
| W8-defer | Six deferred modern-listview JS modules from Wave 8 — keys exist in wave8-js-localization-en-US.json; the 6 sub-modules render English defaults until per-module migration lands. |
Modern listview reaches feature-parity with classic and customer surveys flag the deferred modules. |
The v1.0 contract is stable: window.__cesiviI18n, Cesivi.t/.tHtml/.tResolvePlaceholders,
the bundle JSON shape, and the BundleSeederHostedService discovery regex
will not change. v1.x extensions add capabilities; they do not break the
v1.0 surface.
Cross-wave E2E sanity check (Phase G, PLAN-1346)¶
Cesivi.Tests.WebUI/BrowserTests/MultilingualPhaseGCrossWaveTests.GermanUser_WalksThroughAllWaves_ResolvesGermanChrome
is the closure-evidence test for the Multilingual arc as a whole. It walks
a German-speaking user through every wave's representative surface and
asserts the chrome resolves to the expected German bundle text — proving the
shared layer (auto-discovery, resolver fallback, JS embed) is healthy across
all 9 waves at once.
The per-wave regression tests in
Cesivi.Tests.WebUI/BrowserTests/MultilingualWave{One,Two,...,EightPointFive}Tests.cs
remain authoritative for per-wave coverage. Phase G's role is to catch silent
regressions where a future change to the SHARED layer (resolver fallback,
embed, bundle discovery) breaks one wave without surfacing in that wave's
narrow tests.