Skip to content

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

  1. Click + New resource. A modal dialog opens.
  2. Enter the Key in dotted lowercase form: lists.tasks.title, webparts.welcome.heading, etc. (Convention: namespace.module.thing.)
  3. Choose the Scope:
  4. System — global to the whole farm. Use for shipped, repository-tracked resources.
  5. SiteCollection — overrides the system value for one site collection. Useful when a single tenant wants different wording.
  6. (SiteCollection only) Paste the Site Id (Guid).
  7. (Optional) Default text — used as the very last fallback before the key literal.
  8. 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.
  9. 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

  1. Click Edit in the row's Actions column.
  2. The modal opens pre-populated. The Key field is read-only (changing the key is not supported — delete + create instead).
  3. 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:

  1. Open the admin UI for any resource (or create a new one).
  2. In the modal, type ja-JP in the "Add culture" box and click + Add culture.
  3. 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-AT will not match a stored de-DE translation. 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 by IStringLocalizer<T> consumers running server-side (very few in v1). Configured via Cesivi:Multilingual:* in Cesivi.Server's appsettings.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 via Cesivi:Multilingual:* in Cesivi.WebUI's appsettings.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 IResourceStore provider 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=InMemory do 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_ResolvesGermanChromeAccept-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_ResolvesGermanChromeAccept-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 → &amp;#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 to Cesivi.t for already-embedded keys. The embed mechanism becomes a critical-keys subset.
  • 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. Emits cesivi-i18n:changed DOM event on document for any consumer that wants live re-render.
  • Q3=B — DOM-walker mid-session culture switch. Server-rendered <t/> tag-helper output gains data-i18n-key attributes; Cesivi.switchCulture(newCulture) re-fetches the bundle and walks [data-i18n-key] elements to update text/attributes. Wave 8 emit-side may already write data-i18n-key attributes 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-DE German translation for any key that has a localized value. Missing translations fall through to the en-US default.
  • Is loaded into the configured IResourceStore at startup by BundleSeederHostedService (registered automatically by services.AddCesiviMultilingual(...)). Existing keys in the store are preserved — admin overrides win.

To add or correct a translation:

  1. Open Cesivi.Multilingual/Data/wave1-admin-en-US.json.
  2. Find the entry whose key matches the resource you want to translate.
  3. Add a culture-code property under translations, e.g. "fr-FR": "Filtrer".
  4. 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. multilingual for Multilingual.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 invalidationMultilingualResourceChangedNotifier 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 switchCesivi.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.