Skip to content

V9 - #376

Merged
valdisiljuconoks merged 65 commits into
masterfrom
v9
Jun 3, 2026
Merged

V9#376
valdisiljuconoks merged 65 commits into
masterfrom
v9

Conversation

@valdisiljuconoks

Copy link
Copy Markdown
Owner

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

  • Added a comprehensive CLAUDE.md file to guide Claude AI on project structure, build/test workflow, code style, commit conventions, CI, and especially the mandatory use of grepai for semantic code search and call graph tracing.
  • Added a verify skill markdown file describing the steps and commands for building and testing the codebase, including integration and coverage test options.

Code Style and Formatting Automation

  • Introduced an automated formatting hook in .claude/settings.json that runs dotnet format on relevant .cs files after code changes, ensuring code style consistency across the monorepo.
  • Added and updated .editorconfig files (including in aspnetcore/) 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

  • Updated the main solution index file LocalizationProvider.slnx to accurately reflect the monorepo structure, including all project and documentation files for common, aspnetcore, and optimizely areas.
  • Added the missing DbLocalizationProvider.AspNetCore.Benchmarks project to the aspnetcore/DbLocalizationProvider.Core.sln solution and its build configurations. [1] [2] [3]

General Documentation

  • Updated the README.md to announce v9.0, highlight new features, and provide a tracking issue link, while retaining information about v8.0.

hangy and others added 30 commits December 2, 2024 22:47
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)
2) refactored synchronizer / repo for sql server (now uses tmp+bulk)
valdis and others added 29 commits April 6, 2026 20:43
- 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.
@valdisiljuconoks
valdisiljuconoks merged commit 9d7f9b8 into master Jun 3, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants