V9 - #376
Merged
Merged
Conversation
Co-authored-by: stefanolsen <stefanolsen@users.noreply.github.com>
…n/LocalizationProvider into stefanolsen-feature/replace-jsonconverter
* added resource key builder (otherwise some more advanced resource usage cases will be lost there)
perf: Minor optimizations
Target both .NET 8 and .NET 9
2) refactored synchronizer / repo for sql server (now uses tmp+bulk)
- Add PostToolUse hook for auto-formatting C# files via dotnet format - Add settings.local.json for allowed local commands - Add SKILL.md for build/test verification instructions - Add CLAUDE.md with project, code style, and grepai usage guide - Update .gitignore for local settings and grepai cache - Remove appsettings.Development.json contents from version control
- Target .NET 10.0 across all projects and samples - Update NuGet dependencies to 10.x and EPiServer.CMS.AspNetCore 13.0.0 - Refactor AdminUI and configuration for nullable reference types - Mark MapDbLocalizationAdminUI as obsolete with error - Update EPiServer integration for ISynchronizedObjectInstanceCache - Require key in LocalizationResource constructor; improve nullability - Align with new EPiServer APIs (ReadOnlyMemory<char>[] keys) - Add new docs and images to solution - Clean up code, improve nullability, and use modern C# syntax - Update copyright years and readme for v9.0
Replaces MVC ServiceController and dynamic route provider with minimal API endpoints in AdminUIEndpoints.cs. Updates extension methods and documentation to use MapDbLocalizationAdminUI for endpoint routing, modernizing AdminUI integration and removing controller-based routing dependencies.
Replaces MVC controller/view with a Razor Page hosting the Admin UI via iframe. Introduces HostAreaName constant for routing, updates Razor Pages conventions, and adjusts menu registration to use the new host route. Removes _ViewStart.cshtml and cleans up related code.
Translations for non-master languages silently returned null when the requested culture tag's casing didn't match storage (e.g. ?lang=it-it vs stored it-IT). Master language worked because it is stored as string.Empty everywhere. Switch language-code comparisons to StringComparison.OrdinalIgnoreCase across the in-memory collection, the equality comparer, the storage AddTranslationScript paths (SqlServer + PostgreSQL), the Optimizely provider, and the RemoveTranslation command. Also normalize the clientside provider's ?lang= query parameter via CultureInfo.GetCultureInfo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JSON deserialization (JsonResourceExporter.Deserialize) failed because the collection only exposed a constructor that required an enableInvariantCultureFallback flag, so Newtonsoft could not instantiate it when reading a LocalizationResource's translations array. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds multi-select bulk delete to the AdminUI: a per-row checkbox column with select-all, a "Delete selected (N)" action, and a new POST /api/service/bulk-delete endpoint backed by a BulkDeleteResources command. FromCode resources are silently skipped server-side and visually disabled in the UI. - IResourceRepository.DeleteResources added with single-SQL IN (...) implementations for SqlServer and PostgreSQL (one round trip), per-entity loop for AzureTables. - Cache eviction per deleted key, mirroring DeleteResource.Handler. - Handler unit tests (7) and PostgreSQL integration tests (2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds common/perf/DbLocalizationProvider.Benchmarks with MemoryDiagnoser benchmarks covering the three dominant call shapes downstream sites hit on every page render: - GetString(string, CultureInfo) cache hit, exact culture - GetString(string, CultureInfo) cache hit, parent-culture fallback walk - GetStringByCulture(Expression, CultureInfo) cache hit, expression-key Uses BenchmarkDotNet's in-process toolchain so it does not spawn a child process (avoids antivirus interference on Windows). Adds DbLocalizationProvider.Benchmarks to InternalsVisibleTo of DbLocalizationProvider so the bench can call TypeFactory.SetServiceFactory the same way the AspNetCore integration does. Baseline numbers are captured in the project README and will be used to quantify the upcoming perf fixes (#1-#5 of the design plan). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cache layer normalized keys by calling key.ToLower() on every
Insert/Get/Remove, allocating a new string on every cache hit. This
was the dominant allocator on the page-render hot path - GetString
is called many times per view and almost always lands a cache hit.
Changes:
- BaseCacheManager: drop ToLower() on Insert/Get/Remove. _entries
now uses StringComparer.OrdinalIgnoreCase so case-insensitive lookup
is preserved without per-call allocations.
- BaseCacheManager.Insert: replace TryRemove+TryAdd dance with a
single indexer assignment, defer GetResourceKeyFromCacheKey until
it is actually needed.
- DictionaryBasedCache: use OrdinalIgnoreCase comparer on the inner
ConcurrentDictionary; replace Get's GetOrAdd(key, k => null) -
which allocated a closure even on cache hit - with TryGetValue.
- CacheKeyHelper.BuildKey: replace string interpolation with
string.Concat against a precomputed prefix+separator constant.
- CacheKeyHelper.GetResourceKeyFromCacheKey: replace string.Replace
with a StartsWith+Substring fast path.
Hot-path benchmark (in-process, BenchmarkDotNet, .NET 10):
string-key, exact culture (cache hit)
before: 1.431 us / ~1.5 KB
after: 579 ns / 840 B (-60% time, -44% allocs)
string-key, fr-BE -> fr fallback walk (cache hit)
before: 1.417 us / 1888 B
after: 655 ns / 896 B (-54% time, -53% allocs)
expression-key, exact culture (cache hit)
before: 3.242 us / 2408 B
after: 2.148 us / ~1.5 KB (-34% time)
Behavior change: when the inner ICache implementation is case-
sensitive (e.g. IMemoryCache via the AspNetCore InMemoryCache
wrapper), cache keys are now treated case-sensitively at the inner
layer. The default DictionaryBasedCache remains case-insensitive.
All built-in callers produce keys via ResourceKeyBuilder with
consistent casing, so this affects only external code that mixes
key casing - acceptable on a major version bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GetQueryHandler/GetCommandHandler used to call Activator.CreateInstance on every dispatch to build a generic wrapper around the handler, and GetHandler rebuilt the decorator chain (reflecting over the decorator constructor, allocating a parameter list, then Activator.CreateInstance again) on every dispatch. Built-in handlers and their dependencies are stateless or singleton- backed, so the assembled wrapper+decorator chain can be cached and reused for the lifetime of the TypeFactory. On the steady-state hot path - a cache-hit GetString call - dispatch is now just a single ConcurrentDictionary.TryGetValue plus a cast. Replaces the per-call _wrapperHandlerCache (which only cached the generic-type definition, not the instance) with a single _assembledHandlers dictionary keyed by request type that stores the fully-wired wrapper instance. Hot-path benchmark (cumulative with #1): string-key, exact culture (cache hit) baseline: 1.431 us / ~1.5 KB after #1: 579 ns / 840 B after #4: 195 ns / 392 B (-86% time, -74% allocs vs baseline) string-key, fr-BE -> fr fallback walk (cache hit) baseline: 1.417 us / 1888 B after #1: 655 ns / 896 B after #4: 277 ns / 448 B (-80% time, -76% allocs vs baseline) expression-key, exact culture (cache hit) baseline: 3.242 us / 2408 B after #4: 1.692 us / ~1 KB (-48% time) Behavior change: handler instances are now effectively singleton for the lifetime of the ConfigurationContext. The built-in handlers are already singleton-safe; custom handlers with per-call state (uncommon in practice) need to capture transient deps via a factory rather than constructor injection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GetValueWithFallback was allocating 3 List<CultureInfo> instances per
call via .ToList(), .SkipWhile().ToList(), and .Skip(1).ToList() to
figure out where in the fallback chain to start searching. FindByLanguage
allocated a closure on every call via .FirstOrDefault(predicate).
Both run on every cache-hit translation lookup where the requested
culture has no exact match - extremely common for sites with one or
two translated cultures that fall back for everything else.
Changes:
- FallbackLanguages: implement IReadOnlyList<CultureInfo> (already
backed by a List internally) so callers can index into it without
enumerator allocation.
- GetValueWithFallback: rewrite as an indexed for-loop. Find the
requested culture's position in the chain once, then walk from
position+1. Generic IReadOnlyCollection path retained for forward
compatibility but avoids LINQ.
- FindByLanguage(string?): replace FirstOrDefault(predicate) with an
index-based for-loop. Removes per-call closure allocation.
- ExistsLanguage: delegate to FindByLanguage so optimizations carry.
Hot-path benchmark (cumulative):
string-key, exact culture (cache hit)
baseline: 1.431 us / ~1.5 KB
after #3: 182 ns / 344 B (-87% time, -77% allocs)
string-key, fr-BE -> fr fallback walk (cache hit)
baseline: 1.417 us / 1888 B
after #3: 243 ns / 376 B (-83% time, -80% allocs)
expression-key, exact culture (cache hit)
baseline: 3.242 us / 2408 B
after #3: 1.724 us / 920 B (-47% time, -62% allocs)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GetString(() => Resources.X) re-walked the lambda tree (allocating a
Stack<string>) and re-reflected over the container type (4+ attribute
lookups via GetCustomAttribute / GetMember) on every single call. The
result is purely a function of the leaf member's identity, so it can
be cached.
ExpressionHelper now keeps a ConcurrentDictionary<MemberInfo, string>
keyed by the expression's leaf MemberInfo. The MemberInfo for a given
property is interned with its declaring type's metadata - same property,
same instance regardless of how many times the Expression tree is
rebuilt at the call site - so the cache key is stable and bounded by
the number of distinct resource members in the codebase.
UnaryExpression Convert/ConvertChecked wrappers (added by the compiler
for value-typed property returns inside Expression<Func<object>>) are
peeled off before extracting the MemberInfo. Lambdas that do not have
a MemberExpression at the leaf (e.g. ConstantExpression for enums) are
not cached - those go through the original code path.
Hot-path benchmark (cumulative):
expression-key, exact culture (cache hit)
baseline: 3.242 us / 2408 B
after #4: 1.692 us / ~1 KB
after #2: 196 ns / 262 B (-94% time, -89% allocs vs baseline)
The cache is per-ExpressionHelper-instance; in the default DI wireup
ExpressionHelper is singleton, so the cache persists for the app
lifetime. MemberInfo entries are rooted with their declaring assembly,
so the cache does not leak across unloaded ALCs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BaseCacheManager.Insert was evicting the AllResources dictionary cache on every per-key insert. Insert is the cache-fill path - data is being read from the source of truth and copied into the cache, not modified - so the dictionary entry stays consistent with both the inserted entry and the storage. Mutations still invalidate the dictionary via Remove, which is what CreateOrUpdateTranslation/DeleteResource/BulkDeleteResources already call. Net effect: after a cache fill (typical scenario: first page render populates per-key entries), subsequent AdminUI list requests get an O(1) dictionary hit instead of repopulating the entire collection from storage. Per-key entries inserted later (cache-miss path for new resources) no longer cause the dictionary to refetch from DB on the next AdminUI access either - the dictionary is only refetched when a mutation explicitly invalidates it. Also small clean-up of CacheHelper.CacheManagerOnRemove (the clientside provider hook that walks the cache to invalidate per-container bundle entries): - defer entriesToRemove list allocation until at least one match - use OrdinalIgnoreCase instead of InvariantCultureIgnoreCase (resource keys are ASCII identifiers; ordinal compare is faster and correct) - swap manual GetEnumerator+using for foreach (the using on IEnumerator<string> incurred a virtual Dispose call) Final hot-path numbers vs baseline (cumulative across #1, #4, #3, #2, #5): string-key, exact culture (cache hit) baseline: 1.431 us / ~1.5 KB final: 188 ns / 344 B (-87% time, -77% allocs) string-key, fr-BE -> fr fallback walk (cache hit) baseline: 1.417 us / 1888 B final: 257 ns / 376 B (-82% time, -80% allocs) expression-key, exact culture (cache hit) baseline: 3.242 us / 2408 B final: 203 ns / 344 B (-94% time, -86% allocs) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactored caching to use `CachedTranslations` for compact, culture-indexed storage, replacing `LocalizationResource`. Introduced `WeakReference`-based cache in `BaseCacheManager` to reduce memory footprint. Added `HeapRetentionMeasurement` for memory usage analysis. Updated tests and resource discovery to align with the new caching strategy. Integrated localized resources and updated views and `.csproj` accordingly.
Added no-args overloads for translation methods in ILocalizationProvider, LocalizationProvider, and related extension classes to enable hot-path calls without placeholder formatting. Updated IHtmlHelperOfTExtensions and IStringLocalizerExtensions with new overloads and improved null checking. Added targeted tests and benchmarks for these overloads. Refactored UiHostPage.cshtml for simpler, full-height iframe layout and removed obsolete JS. Cleaned up .gitignore and removed obsolete permissions from settings.local.json. fixes #372
- Introduce NotesAttribute for annotating resources with comments - Add Notes property to DiscoveredResource and LocalizationResource - Update AdminUI to display, edit, and save notes (modal, popover, API) - Preserve notes in CSV/XLIFF import/export with round-trip tests - Update ResourceRepository/Synchronizer to seed and persist notes - Add UpdateResourceNotes command/handler with cache eviction - Add tests for notes discovery, import/export, and update logic - UI/API changes ensure notes editing doesn't affect IsModified - Bump target framework to .NET 10 - Add Csv project reference to test project Closes #148
Upgraded multiple NuGet packages to their latest versions in all .csproj files, including Microsoft.SourceLink.GitHub, Newtonsoft.Json, CsvHelper, BenchmarkDotNet, xunit, coverlet.collector, Testcontainers.PostgreSql, Azure.Data.Tables, Microsoft.Extensions.Options, Npgsql, Microsoft.Data.SqlClient, Azure.AI.Translation.Text, EPiServer.CMS, and Wangkanai.Detection. Also made minor formatting and project reference adjustments. No functional code changes.
Added DbLocalizationProvider.Translator.Azure project reference and UserSecretsId. Updated Startup.cs to enable optional Azure Cognitive Services translation via configuration. Extended appsettings.json with AzureCognitiveServices section for AccessKey and Region.
- Implement batch translation (preview/apply) in Admin UI - Add modals for batch translation and confirmation dialogs - Extend ITranslatorService and Azure translator for batch API - Add endpoints for batch translation preview/apply - Refactor UI to use modals for confirmations - Enhance table selection and resource notes display - Add resource strings and tests for new features Closes #373
Expanded changelog for v9.0: .NET 10 support, nullable reference types, rebuilt Admin UI, automatic and batch translations, per-resource notes, bulk delete, performance enhancements, and bug fixes. Added links to issues and documentation. Replaced previous brief notes with detailed release summary.
Add MongoDB storage provider for DbLocalizationProvider Introduced `DbLocalizationProvider.Storage.MongoDb` project with resource and counter repositories, models, and configuration extensions. Registered MongoDB provider in solution, sample app, and build scripts. Updated README and appsettings for MongoDB support. Made minor improvements to PostgreSQL provider and core abstractions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request introduces several improvements to project documentation, code style enforcement, and developer tooling for the .NET localization provider monorepo. Key changes include new documentation for both project contributors and the Claude AI agent, enhanced code formatting automation, stricter and more consistent code style rules, and updates to solution files to include missing projects.
Documentation and Developer Guidance
CLAUDE.mdfile to guide Claude AI on project structure, build/test workflow, code style, commit conventions, CI, and especially the mandatory use ofgrepaifor semantic code search and call graph tracing.verifyskill markdown file describing the steps and commands for building and testing the codebase, including integration and coverage test options.Code Style and Formatting Automation
.claude/settings.jsonthat runsdotnet formaton relevant.csfiles after code changes, ensuring code style consistency across the monorepo..editorconfigfiles (including inaspnetcore/) to enforce consistent code style rules for C#, XML, JSON, and scripts, as well as custom naming conventions and SonarSource rule suppression. [1] [2]Project and Solution Structure
LocalizationProvider.slnxto accurately reflect the monorepo structure, including all project and documentation files forcommon,aspnetcore, andoptimizelyareas.DbLocalizationProvider.AspNetCore.Benchmarksproject to theaspnetcore/DbLocalizationProvider.Core.slnsolution and its build configurations. [1] [2] [3]General Documentation
README.mdto announce v9.0, highlight new features, and provide a tracking issue link, while retaining information about v8.0.