diff --git a/BlazorLocalization.sln b/BlazorLocalization.sln
index 60ef09e..44d1e3a 100644
--- a/BlazorLocalization.sln
+++ b/BlazorLocalization.sln
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalization.Extensio
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalization.TranslationProvider.Crowdin.Tests", "tests\BlazorLocalization.TranslationProvider.Crowdin.Tests\BlazorLocalization.TranslationProvider.Crowdin.Tests.csproj", "{DAB8B6E9-1B23-40A8-9E68-E04ED1565417}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleBlazorApp", "tests\SampleBlazorApp\SampleBlazorApp.csproj", "{EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -101,16 +103,29 @@ Global
{DAB8B6E9-1B23-40A8-9E68-E04ED1565417}.Release|x64.Build.0 = Release|Any CPU
{DAB8B6E9-1B23-40A8-9E68-E04ED1565417}.Release|x86.ActiveCfg = Release|Any CPU
{DAB8B6E9-1B23-40A8-9E68-E04ED1565417}.Release|x86.Build.0 = Release|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x64.Build.0 = Debug|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x86.Build.0 = Debug|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x64.ActiveCfg = Release|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x64.Build.0 = Release|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x86.ActiveCfg = Release|Any CPU
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
- {70DCB212-441A-4D50-82E8-026D52A368C8} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
- {8F4BE7F1-CC56-4383-8C1D-9F3CAA050B67} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
- {DAB8B6E9-1B23-40A8-9E68-E04ED1565417} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{3CE76414-FCC9-4F89-8DCD-283FEB0A57A7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{510A06EC-13E0-41F4-BF30-B5A27589FC78} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6069D306-E1C1-40CF-8188-F11F19FAC9BA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {70DCB212-441A-4D50-82E8-026D52A368C8} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {8F4BE7F1-CC56-4383-8C1D-9F3CAA050B67} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {DAB8B6E9-1B23-40A8-9E68-E04ED1565417} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index f72d283..bf56390 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ See [Examples](docs/Examples.md) for plurals, ordinals, enum display names, and
| Package | Version | Install |
|---------|:-------:|--------:|
| [**BlazorLocalization.Extensions**](https://www.nuget.org/packages/BlazorLocalization.Extensions)
Caches translations, supports plurals and inline translations, pluggable translation providers | [](https://www.nuget.org/packages/BlazorLocalization.Extensions) | `dotnet add package BlazorLocalization.Extensions` |
-| [**BlazorLocalization.Extractor**](https://www.nuget.org/packages/BlazorLocalization.Extractor)
CLI tool (`blazor-loc`) β Roslyn-based scanner that extracts source strings from `.razor`, `.cs`, and `.resx` files | [](https://www.nuget.org/packages/BlazorLocalization.Extractor) | `dotnet tool install -g BlazorLocalization.Extractor` |
+| [**BlazorLocalization.Extractor**](https://www.nuget.org/packages/BlazorLocalization.Extractor)
CLI tool (`blazor-loc`) β inspect translation health and extract source strings from `.razor`, `.cs`, and `.resx` files | [](https://www.nuget.org/packages/BlazorLocalization.Extractor) | `dotnet tool install -g BlazorLocalization.Extractor` |
Translation providers:
@@ -109,24 +109,43 @@ Built on [Microsoft's `IStringLocalizer`](https://learn.microsoft.com/en-us/aspn
---
-## π¬ String Extraction
+## π¬ CLI Tool β Inspect & Extract
-Already using `IStringLocalizer`? The Extractor scans your `.razor`, `.cs`, and `.resx` files and exports every translation string β no matter which localization backend you use.
-
-
+A Roslyn-based CLI that understands both BlazorLocalization's `Translation()` API and Microsoft's built-in `IStringLocalizer["key"]` + `.resx` β no code changes or adoption required. Point it at your project and go.
```bash
dotnet tool install -g BlazorLocalization.Extractor
+```
-# Interactive wizard β run with no arguments
-blazor-loc
+### Inspect: Translation Health Audit
+
+```bash
+blazor-loc inspect ./src
+```
+
+You get a table of every translation key, where it's used, its source text, which locales have it, and whether anything is wrong. Spot duplicate keys with conflicting values, `IStringLocalizer` calls the scanner couldn't resolve, and `.resx` entries that no code references anymore β across hundreds of files in seconds.
+
+### Extract: Keep Translations in Sync
+
+Without tooling, keeping your translation platform in sync with your code means manually copying strings β every key, every language, every time something changes. There's no built-in way to get translation strings out of a .NET project and into Crowdin, Lokalise, a database, or anywhere else.
-# Or go direct
+```bash
blazor-loc extract ./src -f po -o ./translations
```
-Upload the generated files to Crowdin, Lokalise, or any translation management system.
-See [Extractor CLI](docs/Extractor.md) for recipes, CI integration, and export formats.
+One command scans your entire codebase and exports every source string to PO, i18next JSON, or generic JSON. Run it in CI on every merge and your translation platform always has the latest strings.
+
+### Interactive Wizard
+
+Run with no arguments for a guided walkthrough:
+
+
+
+```bash
+blazor-loc
+```
+
+See [Extractor CLI](docs/Extractor.md) for all recipes, CI integration, and export formats.
---
@@ -141,7 +160,7 @@ See [Extractor CLI](docs/Extractor.md) for recipes, CI integration, and export f
| Named placeholders | β | β | β β via SmartFormat |
| External provider support | β | β | β β pluggable |
| Merge-conflict-free | β β XML | β β PO files | β β with OTA providers. File-based providers are opt-in |
-| Automated string extraction | Manual | Manual | Roslyn-based CLI |
+| Translation audit + extraction | Manual | Manual | Roslyn-based CLI β inspect and export |
| Reusable definitions | β | β | β β define once, use anywhere |
| Standard `IStringLocalizer` | β | β | β |
| Battle-tested | β β 20+ years | β | Production use, actively maintained |
@@ -155,7 +174,7 @@ See [Extractor CLI](docs/Extractor.md) for recipes, CI integration, and export f
| Topic | Description |
|-------|-------------|
| [Examples](docs/Examples.md) | `Translation()` usage β simple, placeholders, plurals, ordinals, select, inline translations, reusable definitions |
-| [Extractor CLI](docs/Extractor.md) | Install, interactive wizard, common recipes, CI integration, export formats |
+| [Extractor CLI](docs/Extractor.md) | Install, inspect & extract commands, interactive wizard, CI integration, export formats |
| [Configuration](docs/Configuration.md) | Cache settings, `appsettings.json` binding, multiple providers, code-only config |
| [Crowdin Provider](docs/Providers/Crowdin.md) | Over-the-air translations from Crowdin β distribution hash, export formats, error handling |
| [JSON File Provider](docs/Providers/JsonFile.md) | Load translations from flat JSON files on disk |
diff --git a/docs/Extractor.md b/docs/Extractor.md
index 864dd89..54619bc 100644
--- a/docs/Extractor.md
+++ b/docs/Extractor.md
@@ -2,9 +2,10 @@
# Extractor CLI
-`blazor-loc` scans your `.razor`, `.cs`, and `.resx` files and exports source strings to translation files. Upload the output to Crowdin, Lokalise, or any translation management system.
+`blazor-loc` is a Roslyn-based CLI that understands both BlazorLocalization's `Translation()` API and Microsoft's built-in `IStringLocalizer["key"]` + `.resx`. No code changes or adoption required β point it at any `IStringLocalizer` project and go.
-It works with any `IStringLocalizer` codebase β regardless of which localization backend you use.
+- **`inspect`** β Translation health audit. See every translation key, where it's used, what's missing, what conflicts, and how complete each locale is.
+- **`extract`** β Scan your codebase and export every source string. Run it in CI on every merge to keep your translation platform in sync.
## Install
@@ -28,7 +29,9 @@ Run with no arguments to launch the interactive wizard. It walks you through pro
blazor-loc
```
-## Common Recipes
+## Extract
+
+Without tooling, keeping translations in sync means manually copying strings between your code and your translation platform β every key, every language, every time something changes. `extract` scans your entire codebase and exports every source string in one command. Run it locally or in CI on every merge.
Extract to Crowdin i18next JSON (the default format):
@@ -84,19 +87,46 @@ Narrow to specific locales:
blazor-loc extract ./src -f i18next -o ./translations -l da -l es-MX
```
-Debug what the scanner detects (raw calls + merged entries):
+## Inspect
+
+Point `inspect` at your project and get a translation health audit β the full picture across every file, locale, and pattern in seconds.
```bash
blazor-loc inspect ./src
```
-Output as JSON (pipeable to other tools):
+### What you see
+
+**Translation entries** β one row per unique key, showing where it's used in your code, its source text, which form it takes (simple, plural, select...), and which locales have a translation. Spot a key that's missing `de` when every other row has it.
+
+**Conflicts** β same key used with different source texts in different places. Almost always a bug. The table shows exactly which files disagree and what each one says.
+
+**Extraction warnings** β the handful of calls the scanner couldn't confidently resolve. Things like `Loc[someVar ? Loc["..."] : Loc["..."]]` or mangled expressions that somehow made it through code review. By default, only these problem cases surface β not the hundreds of healthy calls.
+
+**Locale coverage** β per-language summary: how many keys each locale has, what percentage that covers, and any keys that only exist in one locale but not the source. At a glance you see that `es-MX` is at 97.6% but `vi` is at 85.7%.
+
+**Cross-reference summary** β one line bridging code and data: how many keys resolved, how many are missing, how many `.resx` entries have no matching code reference.
+
+### Options
+
+See full key/value tables per language (instead of the default summary):
+
+```bash
+blazor-loc inspect ./src --show-resx-locales
+```
+
+See every line of code where a translation was found (including all healthy ones):
```bash
-blazor-loc inspect ./src --json
+blazor-loc inspect ./src --show-extracted-calls
```
-`inspect` dumps every detected `IStringLocalizer` call with its key, source text, plural forms, and file location β useful for verifying the scanner found what you expected.
+Output as JSON (auto-enabled when stdout is piped):
+
+```bash
+blazor-loc inspect ./src --json
+blazor-loc inspect ./src | jq '.translationEntries[] | select(.status == "Missing")'
+```
## Export Formats
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractCommand.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractCommand.cs
new file mode 100644
index 0000000..2ea6a29
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractCommand.cs
@@ -0,0 +1,252 @@
+using BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+using BlazorLocalization.Extractor.Adapters.Export;
+using BlazorLocalization.Extractor.Application;
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Commands;
+
+///
+/// Extracts translation entries from Blazor projects and exports them in the specified format.
+///
+internal sealed class ExtractCommand : Command
+{
+ protected override int Execute(CommandContext context, ExtractSettings settings, CancellationToken cancellationToken)
+ {
+ // 1. Resolve paths
+ var (projectDirs, resolveErrors) = ProjectDiscovery.ResolveAll(settings.Paths);
+ foreach (var err in resolveErrors)
+ Console.Error.WriteLine(err);
+ if (resolveErrors.Count > 0)
+ return 1;
+
+ // 2. Build request
+ var localeFilter = settings.Locales is { Length: > 0 }
+ ? new HashSet(settings.Locales, StringComparer.OrdinalIgnoreCase)
+ : null;
+
+ var request = new ExtractRequest(
+ ProjectDirs: projectDirs,
+ Format: settings.Format,
+ Output: OutputTarget.FromRawOutput(settings.Output),
+ LocaleFilter: localeFilter,
+ SourceOnly: settings.SourceOnly,
+ PathStyle: settings.PathStyle,
+ Verbose: settings.Verbose,
+ ExitOnDuplicateKey: settings.ExitOnDuplicateKey,
+ OnDuplicateKey: settings.OnDuplicateKey);
+
+ // 3. Validate
+ var errors = request.Validate();
+ if (errors.Count > 0)
+ {
+ foreach (var error in errors)
+ Console.Error.WriteLine(error);
+ return 1;
+ }
+
+ // 4. Execute
+ return request.Output switch
+ {
+ OutputTarget.StdoutTarget => ExecuteStdout(request, cancellationToken),
+ OutputTarget.FileTarget f => ExecuteFile(request, f.Path, cancellationToken),
+ OutputTarget.DirTarget d => ExecuteDir(request, d.Path, cancellationToken),
+ _ => 1
+ };
+ }
+
+ private static int ExecuteStdout(ExtractRequest request, CancellationToken cancellationToken)
+ {
+ var projectDir = request.ProjectDirs[0];
+ var scan = ProjectScanner.Scan(projectDir, cancellationToken);
+ var entries = ApplyConflictStrategy(scan.MergeResult, request);
+ entries = FilterUnresolvableReferences(entries);
+
+ WarnMissingLocales(entries, request);
+
+ if (request.LocaleFilter is { Count: 1 })
+ {
+ var locale = request.LocaleFilter.First();
+ entries = LocaleDiscovery.EntriesForLocale(entries, locale);
+ }
+
+ var exporter = ExporterFactory.Create(request.Format);
+ Console.Write(exporter.Export(entries, request.PathStyle));
+
+ foreach (var conflict in scan.MergeResult.Conflicts)
+ Console.Error.WriteLine($"Warning: duplicate key '{conflict.Key}' with {conflict.Values.Count} different values");
+
+ foreach (var invalid in scan.MergeResult.InvalidEntries)
+ Console.Error.WriteLine($"Warning: invalid translation skipped β {invalid.Reason} at {string.Join(", ", invalid.Sites.Select(s => s.File.FileName))}");
+
+ return request.ExitOnDuplicateKey && scan.MergeResult.Conflicts.Count > 0 ? 1 : 0;
+ }
+
+ private static int ExecuteFile(ExtractRequest request, string filePath, CancellationToken cancellationToken)
+ {
+ var projectDir = request.ProjectDirs[0];
+ var scan = ProjectScanner.Scan(projectDir, cancellationToken);
+ var entries = ApplyConflictStrategy(scan.MergeResult, request);
+ entries = FilterUnresolvableReferences(entries);
+
+ var dir = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ var exporter = ExporterFactory.Create(request.Format);
+ System.IO.File.WriteAllText(filePath, exporter.Export(entries, request.PathStyle));
+
+ if (request.Verbose)
+ AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]");
+
+ ReportConflicts(scan.MergeResult.Conflicts, scan.ProjectName);
+ ReportInvalidEntries(scan.MergeResult.InvalidEntries, scan.ProjectName);
+ return request.ExitOnDuplicateKey && scan.MergeResult.Conflicts.Count > 0 ? 1 : 0;
+ }
+
+ private static int ExecuteDir(ExtractRequest request, string outputDir, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(outputDir);
+ var exporter = ExporterFactory.Create(request.Format);
+ var ext = ExporterFactory.GetFileExtension(request.Format);
+ var hasConflicts = false;
+ var summaryRows = new List<(string Project, int Entries, int Conflicts)>();
+
+ foreach (var projectDir in request.ProjectDirs)
+ {
+ ProjectScanResult scan;
+
+ if (AnsiConsole.Profile.Capabilities.Interactive)
+ {
+ scan = null!;
+ AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .Start($"Scanning [blue]{Markup.Escape(Path.GetFileName(projectDir))}[/]...", _ =>
+ {
+ scan = ProjectScanner.Scan(projectDir, cancellationToken);
+ });
+ }
+ else
+ {
+ scan = ProjectScanner.Scan(projectDir, cancellationToken);
+ }
+
+ var entries = ApplyConflictStrategy(scan.MergeResult, request);
+ entries = FilterUnresolvableReferences(entries);
+ if (scan.MergeResult.Conflicts.Count > 0) hasConflicts = true;
+
+ if (request.Verbose)
+ AnsiConsole.MarkupLine($"[dim]{Markup.Escape(scan.ProjectName)}: {entries.Count} translation(s)[/]");
+
+ var filePath = Path.Combine(outputDir, $"{scan.ProjectName}{ext}");
+ System.IO.File.WriteAllText(filePath, exporter.Export(entries, request.PathStyle));
+ if (request.Verbose)
+ AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]");
+
+ if (!request.SourceOnly)
+ ExportPerLocaleFiles(entries, request, outputDir, scan.ProjectName, exporter, ext);
+
+ summaryRows.Add((scan.ProjectName, entries.Count, scan.MergeResult.Conflicts.Count));
+ ReportConflicts(scan.MergeResult.Conflicts, scan.ProjectName);
+ ReportInvalidEntries(scan.MergeResult.InvalidEntries, scan.ProjectName);
+
+ if (request.Format is ExportFormat.Po)
+ PoLimitationRenderer.Render(PoLimitation.Detect(entries), scan.ProjectName);
+ }
+
+ if (summaryRows.Count > 0)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule("[blue]Summary[/]").LeftJustified());
+ AnsiConsole.WriteLine();
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .AddColumn("Project")
+ .AddColumn("Entries")
+ .AddColumn("Conflicts");
+
+ foreach (var (project, entries, conflicts) in summaryRows)
+ {
+ var conflictMarkup = conflicts > 0 ? $"[yellow]{conflicts}[/]" : $"[green]{conflicts}[/]";
+ table.AddRow(Markup.Escape(project), entries.ToString(), conflictMarkup);
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ return hasConflicts && request.ExitOnDuplicateKey ? 1 : 0;
+ }
+
+ private static IReadOnlyList ApplyConflictStrategy(
+ MergeResult mergeResult, ExtractRequest request)
+ {
+ var entries = mergeResult.Entries;
+ if (mergeResult.Conflicts.Count > 0 && request.OnDuplicateKey is ConflictStrategy.Skip)
+ {
+ var conflictKeys = mergeResult.Conflicts.Select(c => c.Key).ToHashSet();
+ entries = entries.Where(e => !conflictKeys.Contains(e.Key)).ToList();
+ }
+ return entries;
+ }
+
+ private static IReadOnlyList FilterUnresolvableReferences(
+ IReadOnlyList entries)
+ {
+ return entries.Where(e => e.IsKeyLiteral || e.SourceText is not null).ToList();
+ }
+
+ private static void ExportPerLocaleFiles(
+ IReadOnlyList entries,
+ ExtractRequest request,
+ string outputDir,
+ string projectName,
+ ITranslationExporter exporter,
+ string ext)
+ {
+ var locales = LocaleDiscovery.DiscoverLocales(entries, request.LocaleFilter);
+
+ if (request.LocaleFilter is not null)
+ WarnMissingLocales(entries, request);
+
+ foreach (var locale in locales)
+ {
+ var localeEntries = LocaleDiscovery.EntriesForLocale(entries, locale);
+ var filePath = Path.Combine(outputDir, $"{projectName}.{locale}{ext}");
+ System.IO.File.WriteAllText(filePath, exporter.Export(localeEntries, request.PathStyle));
+ if (request.Verbose)
+ AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]");
+ }
+ }
+
+ private static void WarnMissingLocales(IReadOnlyList entries, ExtractRequest request)
+ {
+ if (request.LocaleFilter is null) return;
+
+ var discovered = LocaleDiscovery.DiscoverLocales(entries);
+ foreach (var requested in request.LocaleFilter.Where(r =>
+ !discovered.Contains(r, StringComparer.OrdinalIgnoreCase)))
+ {
+ Console.Error.WriteLine($"Warning: locale '{requested}' not found in any translation");
+ }
+ }
+
+ private static void ReportConflicts(IReadOnlyList conflicts, string projectName)
+ {
+ ConflictRenderer.Render(conflicts, projectName);
+ }
+
+ private static void ReportInvalidEntries(IReadOnlyList invalidEntries, string projectName)
+ {
+ if (invalidEntries.Count == 0) return;
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine($"[red]β {invalidEntries.Count} invalid translation(s) skipped in {Markup.Escape(projectName)}[/]");
+ foreach (var entry in invalidEntries)
+ {
+ var locations = string.Join(", ", entry.Sites.Select(s => s.File.FileName));
+ AnsiConsole.MarkupLine($" [red]{Markup.Escape(entry.Reason)}[/] at [dim]{Markup.Escape(locations)}[/]");
+ }
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractRequest.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractRequest.cs
new file mode 100644
index 0000000..20b659d
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractRequest.cs
@@ -0,0 +1,67 @@
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Commands;
+
+///
+/// Validated request for the extract command.
+/// Maps CLI settings into domain types with validation.
+///
+public sealed record ExtractRequest(
+ IReadOnlyList ProjectDirs,
+ ExportFormat Format,
+ OutputTarget Output,
+ HashSet? LocaleFilter,
+ bool SourceOnly,
+ PathStyle PathStyle,
+ bool Verbose,
+ bool ExitOnDuplicateKey,
+ ConflictStrategy OnDuplicateKey)
+{
+ public IReadOnlyList Validate()
+ {
+ var errors = new List();
+
+ if (ProjectDirs.Count == 0)
+ errors.Add("No projects to scan.");
+
+ if (SourceOnly && LocaleFilter is { Count: > 0 })
+ errors.Add("--source-only and --locale cannot be used together.");
+
+ if (Output is OutputTarget.StdoutTarget)
+ {
+ if (ProjectDirs.Count > 1)
+ errors.Add("Stdout output supports only a single project. Use -o for multiple projects.");
+ }
+
+ if (Output is OutputTarget.FileTarget)
+ {
+ if (ProjectDirs.Count > 1)
+ errors.Add("File output supports only a single project. Use -o for multiple projects.");
+ }
+
+ return errors;
+ }
+}
+
+///
+/// Where extract output goes. Classified from the raw -o value.
+///
+public abstract record OutputTarget
+{
+ public sealed record StdoutTarget : OutputTarget;
+ public sealed record FileTarget(string Path) : OutputTarget;
+ public sealed record DirTarget(string Path) : OutputTarget;
+
+ ///
+ /// Classifies the raw -o value: null β stdout, has extension β file, else β directory.
+ ///
+ public static OutputTarget FromRawOutput(string? raw)
+ {
+ if (raw is null)
+ return new StdoutTarget();
+
+ return Path.HasExtension(raw)
+ ? new FileTarget(raw)
+ : new DirTarget(raw);
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractSettings.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractSettings.cs
new file mode 100644
index 0000000..e65c1eb
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractSettings.cs
@@ -0,0 +1,35 @@
+using System.ComponentModel;
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console.Cli;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Commands;
+
+///
+/// CLI settings for the extract command.
+///
+public sealed class ExtractSettings : SharedSettings
+{
+ [Description("Export format: i18next (default, Crowdin i18next JSON), po (GNU Gettext with source refs), json (full-fidelity debug)")]
+ [CommandOption("-f|--format")]
+ [DefaultValue(ExportFormat.I18Next)]
+ public ExportFormat Format { get; init; } = ExportFormat.I18Next;
+
+ [Description("Output path: a file (e.g. out.po) or directory (e.g. ./translations). Auto-detected via extension. Omit to write to stdout")]
+ [CommandOption("-o|--output")]
+ public string? Output { get; init; }
+
+ [Description("Print per-project scanning progress to stderr")]
+ [CommandOption("--verbose")]
+ [DefaultValue(false)]
+ public bool Verbose { get; init; }
+
+ [Description("Exit with error code 1 when duplicate translation keys are found (useful for CI pipelines)")]
+ [CommandOption("--exit-on-duplicate-key")]
+ [DefaultValue(false)]
+ public bool ExitOnDuplicateKey { get; init; }
+
+ [Description("Strategy for duplicate translation keys (same key, different source text). 'first' (default) keeps the first-seen source text. 'skip' omits the key entirely")]
+ [CommandOption("--on-duplicate-key")]
+ [DefaultValue(ConflictStrategy.First)]
+ public ConflictStrategy OnDuplicateKey { get; init; } = ConflictStrategy.First;
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectCommand.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectCommand.cs
new file mode 100644
index 0000000..0a69ea1
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectCommand.cs
@@ -0,0 +1,111 @@
+using BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+using BlazorLocalization.Extractor.Adapters.Roslyn;
+using BlazorLocalization.Extractor.Application;
+using Spectre.Console.Cli;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Commands;
+
+///
+/// Translation health audit: shows code references, .resx entries, cross-references, and locale coverage.
+///
+internal sealed class InspectCommand : Command
+{
+ protected override int Execute(CommandContext context, InspectSettings settings, CancellationToken cancellationToken)
+ {
+ // 1. Resolve paths
+ var (projectDirs, resolveErrors) = ProjectDiscovery.ResolveAll(settings.Paths);
+ foreach (var err in resolveErrors)
+ Console.Error.WriteLine(err);
+ if (resolveErrors.Count > 0)
+ return 1;
+
+ // 2. Build request
+ var localeFilter = settings.Locales is { Length: > 0 }
+ ? new HashSet(settings.Locales, StringComparer.OrdinalIgnoreCase)
+ : null;
+
+ var request = new InspectRequest(
+ ProjectDirs: projectDirs,
+ JsonOutput: settings.ShouldOutputJson,
+ LocaleFilter: localeFilter,
+ ShowResxLocales: settings.ShowResxLocales,
+ ShowExtractedCalls: settings.ShowExtractedCalls,
+ PathStyle: settings.PathStyle);
+
+ // 3. Validate
+ var errors = request.Validate();
+ if (errors.Count > 0)
+ {
+ foreach (var error in errors)
+ Console.Error.WriteLine(error);
+ return 1;
+ }
+
+ // 4. Execute
+ foreach (var projectDir in request.ProjectDirs)
+ {
+ var scan = ProjectScanner.Scan(projectDir, cancellationToken);
+
+ if (request.LocaleFilter is not null)
+ {
+ var discovered = LocaleDiscovery.DiscoverLocales(scan.MergeResult.Entries);
+ foreach (var requested in request.LocaleFilter.Where(r =>
+ !discovered.Contains(r, StringComparer.OrdinalIgnoreCase)))
+ {
+ Console.Error.WriteLine($"Warning: locale '{requested}' not found in any translation");
+ }
+ }
+
+ if (request.JsonOutput)
+ {
+ JsonRenderer.RenderInspect(
+ scan.ProjectName,
+ scan.MergeResult.Entries,
+ scan.MergeResult.Conflicts,
+ scan.MergeResult.InvalidEntries,
+ request.LocaleFilter,
+ request.PathStyle);
+ }
+ else
+ {
+ // 1. Extracted calls (opt-in) β downcast to adapter type
+ if (request.ShowExtractedCalls)
+ {
+ var roslynOutput = scan.ScannerOutputs.OfType().FirstOrDefault();
+ if (roslynOutput is not null)
+ ExtractedCallRenderer.Render(roslynOutput.RawCalls, scan.ProjectName, request.PathStyle);
+ }
+
+ // 2. Translation Entries
+ TranslationEntryRenderer.Render(scan.MergeResult.Entries, scan.ProjectName, request.PathStyle);
+
+ // 3. Conflicts
+ TranslationEntryRenderer.RenderConflicts(scan.MergeResult.Conflicts, request.PathStyle);
+
+ // 4. Invalid entries
+ TranslationEntryRenderer.RenderInvalidEntries(scan.MergeResult.InvalidEntries, request.PathStyle);
+
+ // 5. .resx Files
+ if (request.ShowResxLocales)
+ TranslationEntryRenderer.RenderResxLocales(scan.MergeResult.Entries, request.LocaleFilter);
+ else
+ TranslationEntryRenderer.RenderResxOverview(scan.MergeResult.Entries, request.LocaleFilter);
+
+ // 6. Anomalies
+ TranslationEntryRenderer.RenderAnomalies(scan.MergeResult.Entries, request.LocaleFilter);
+
+ // 7. Cross-reference summary
+ TranslationEntryRenderer.RenderCrossReferenceSummary(scan.MergeResult.Entries, scan.ProjectName);
+
+ // 8. Legend
+ var hasResxFiles = scan.MergeResult.Entries.Any(e =>
+ e.Definitions.Any(d => d.File.IsResx));
+ TranslationEntryRenderer.RenderLegend(
+ scan.MergeResult.Entries, hasResxFiles, request.ShowExtractedCalls,
+ scan.MergeResult.InvalidEntries.Count > 0);
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectRequest.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectRequest.cs
new file mode 100644
index 0000000..da46680
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectRequest.cs
@@ -0,0 +1,25 @@
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Commands;
+
+///
+/// Validated request for the inspect command.
+///
+public sealed record InspectRequest(
+ IReadOnlyList ProjectDirs,
+ bool JsonOutput,
+ HashSet? LocaleFilter,
+ bool ShowResxLocales,
+ bool ShowExtractedCalls,
+ PathStyle PathStyle)
+{
+ public IReadOnlyList Validate()
+ {
+ var errors = new List();
+
+ if (ProjectDirs.Count == 0)
+ errors.Add("No projects to scan.");
+
+ return errors;
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectSettings.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectSettings.cs
new file mode 100644
index 0000000..c253620
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectSettings.cs
@@ -0,0 +1,30 @@
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Commands;
+
+///
+/// CLI settings for the inspect command.
+///
+public sealed class InspectSettings : SharedSettings
+{
+ [Description("Output raw JSON to stdout instead of rendered tables. Auto-enabled when stdout is piped")]
+ [CommandOption("--json")]
+ [DefaultValue(false)]
+ public bool Json { get; init; }
+
+ [Description("Show full .resx tables for each language (default: summary only)")]
+ [CommandOption("--show-resx-locales")]
+ [DefaultValue(false)]
+ public bool ShowResxLocales { get; init; }
+
+ [Description("Show every line of code where a translation was found (default: warnings only)")]
+ [CommandOption("--show-extracted-calls")]
+ [DefaultValue(false)]
+ public bool ShowExtractedCalls { get; init; }
+
+ ///
+ /// Whether output should be JSON β explicitly via --json or auto-detected when stdout is piped.
+ ///
+ public bool ShouldOutputJson => Json || Console.IsOutputRedirected;
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/SharedSettings.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/SharedSettings.cs
new file mode 100644
index 0000000..bc3f1ce
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/SharedSettings.cs
@@ -0,0 +1,29 @@
+using System.ComponentModel;
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console.Cli;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Commands;
+
+///
+/// Shared CLI settings for path discovery and output options.
+///
+public class SharedSettings : CommandSettings
+{
+ [Description("Directories or .csproj files to scan. Repeatable. Defaults to current directory")]
+ [CommandArgument(0, "[paths]")]
+ public string[] Paths { get; init; } = ["."];
+
+ [Description("Path style for source references. 'relative' (default) emits paths relative to the project root. 'absolute' preserves full filesystem paths")]
+ [CommandOption("--path-style")]
+ [DefaultValue(PathStyle.Relative)]
+ public PathStyle PathStyle { get; init; } = PathStyle.Relative;
+
+ [Description("Filter to specific locales. Repeatable (-l da -l es-MX). Omit to include all discovered locales")]
+ [CommandOption("-l|--locale")]
+ public string[]? Locales { get; init; }
+
+ [Description("Export source-language strings only; skip per-locale translations")]
+ [CommandOption("--source-only")]
+ [DefaultValue(false)]
+ public bool SourceOnly { get; init; }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/InteractiveWizard.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/InteractiveWizard.cs
new file mode 100644
index 0000000..157899d
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/InteractiveWizard.cs
@@ -0,0 +1,168 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.Reflection;
+using BlazorLocalization.Extractor.Adapters.Export;
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli;
+
+///
+/// Interactive wizard that gathers CLI arguments via Spectre.Console prompts
+/// when the tool is invoked with no arguments.
+///
+internal static class InteractiveWizard
+{
+ public static string[] Run()
+ {
+ WriteBanner();
+ AnsiConsole.WriteLine();
+
+ var command = PromptWithDescriptions("What would you like to do?", new Dictionary
+ {
+ ["extract"] = "Scan your projects and export source strings to translation files",
+ ["inspect"] = "Check your translation setup β find missing, conflicting, or unused keys"
+ });
+
+ var path = AnsiConsole.Prompt(
+ new TextPrompt("Project/solution [green]root path[/]:")
+ .DefaultValue("."));
+
+ var discovered = ProjectDiscovery.Discover(path);
+ if (discovered.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[red]No projects found at that path.[/]");
+ return [command, path];
+ }
+
+ var selectedPaths = SelectProjects(discovered, path);
+
+ var args = new List { command };
+ foreach (var proj in selectedPaths)
+ args.Add(proj);
+
+ if (command == "extract")
+ AddExtractOptions(args);
+ else if (command == "inspect")
+ AddInspectOptions(args);
+
+ AnsiConsole.WriteLine();
+ return args.ToArray();
+ }
+
+ private static void WriteBanner()
+ {
+ var blazorPurple = new Color(81, 43, 212);
+ var lightPurple = new Color(140, 100, 240);
+
+ AnsiConsole.Write(new FigletText("Blazor") { Color = blazorPurple, Justification = Justify.Center });
+ AnsiConsole.Write(new FigletText("Localization") { Color = lightPurple, Justification = Justify.Center });
+ var version = typeof(InteractiveWizard).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion.Split('+')[0] ?? "unknown";
+ AnsiConsole.Write(new Text($"v{version}", new Style(blazorPurple, decoration: Decoration.Dim)) { Justification = Justify.Center });
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Text("Scan your projects for translation strings β extract, inspect, and export.", new Style(Color.Default, decoration: Decoration.Dim)) { Justification = Justify.Center });
+ AnsiConsole.WriteLine();
+ }
+
+ private static IReadOnlyList SelectProjects(IReadOnlyList discovered, string root)
+ {
+ if (discovered.Count == 1)
+ {
+ AnsiConsole.MarkupLine($"Found project: [green]{Path.GetFileName(discovered[0])}[/]");
+ return discovered;
+ }
+
+ var rootFull = Path.GetFullPath(root);
+ var displayToPath = discovered.ToDictionary(
+ d => Path.GetRelativePath(rootFull, d),
+ d => d);
+
+ var sortedKeys = displayToPath.Keys
+ .OrderBy(k => k, StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering))
+ .ToList();
+
+ var prompt = new MultiSelectionPrompt()
+ .Title("Select [green]projects[/] to scan:")
+ .PageSize(15)
+ .InstructionsText("[dim](Press [blue][/] to toggle, [green][/] to accept)[/]")
+ .AddChoices(sortedKeys);
+
+ foreach (var name in displayToPath.Keys)
+ prompt.Select(name);
+
+ var selected = AnsiConsole.Prompt(prompt);
+ return selected.Select(name => displayToPath[name]).ToList();
+ }
+
+ private static void AddExtractOptions(List args)
+ {
+ var format = PromptEnum("Output [green]format[/]:");
+ if (format is ExportFormat.Po)
+ AnsiConsole.MarkupLine("[yellow]βΉ PO format has limitations: it cannot represent exact value matches (e.g. 'exactly 0' or 'exactly 42') or ordinal forms (1st, 2nd, 3rd). Affected entries will be flagged below.[/]");
+ args.Add("-f");
+ args.Add(format.ToString());
+
+ var outputDir = AnsiConsole.Prompt(
+ new TextPrompt("Output [green]directory[/]:")
+ .DefaultValue("./output"));
+ args.Add("-o");
+ args.Add(outputDir);
+
+ var ext = ExporterFactory.GetFileExtension(format);
+ if (!AnsiConsole.Confirm($"Include per-locale translations? (e.g. MyApp.da[green]{ext}[/], MyApp.es-MX[green]{ext}[/])", true))
+ args.Add("--source-only");
+
+ var onDuplicateKey = PromptEnum("Duplicate translation key [green]strategy[/]:");
+ if (onDuplicateKey is not ConflictStrategy.First)
+ {
+ args.Add("--on-duplicate-key");
+ args.Add(onDuplicateKey.ToString());
+ }
+
+ if (AnsiConsole.Confirm("Verbose output?", false))
+ args.Add("--verbose");
+ }
+
+ private static void AddInspectOptions(List args)
+ {
+ var detail = PromptWithDescriptions("How much [green]detail[/]?", new Dictionary
+ {
+ ["Standard"] = "Translation health check β problems highlighted (recommended)",
+ ["Everything"] = "Every .resx entry per language and every code location found"
+ });
+
+ if (detail == "Everything")
+ {
+ args.Add("--show-resx-locales");
+ args.Add("--show-extracted-calls");
+ }
+ }
+
+ private static T PromptEnum(string title) where T : struct, Enum
+ {
+ var values = Enum.GetValues();
+ var prompt = new SelectionPrompt()
+ .Title(title)
+ .UseConverter(v =>
+ {
+ var desc = typeof(T).GetField(v.ToString())?
+ .GetCustomAttribute()?.Description;
+ return desc is not null ? $"{v} [dim]β {desc}[/]" : v.ToString();
+ })
+ .AddChoices(values);
+
+ return AnsiConsole.Prompt(prompt);
+ }
+
+ private static string PromptWithDescriptions(string title, Dictionary choices)
+ {
+ var prompt = new SelectionPrompt()
+ .Title(title)
+ .UseConverter(key => choices.TryGetValue(key, out var desc) ? $"{key} [dim]β {desc}[/]" : key)
+ .AddChoices(choices.Keys);
+
+ return AnsiConsole.Prompt(prompt);
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/ProjectDiscovery.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/ProjectDiscovery.cs
new file mode 100644
index 0000000..43cf626
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/ProjectDiscovery.cs
@@ -0,0 +1,82 @@
+namespace BlazorLocalization.Extractor.Adapters.Cli;
+
+///
+/// Discovers .csproj project directories by scanning a root path recursively.
+/// Skips common build-output and dependency directories.
+///
+internal static class ProjectDiscovery
+{
+ private static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "obj", "bin", "node_modules", ".git", ".vs", ".idea"
+ };
+
+ ///
+ /// Resolves one or more input paths (directories or .csproj files) into
+ /// deduplicated project directories. Returns errors for paths that don't exist.
+ ///
+ public static (IReadOnlyList ProjectDirs, IReadOnlyList Errors) ResolveAll(string[] paths)
+ {
+ var dirs = new List();
+ var errors = new List();
+
+ foreach (var raw in paths)
+ {
+ var expanded = ExpandPath(raw);
+
+ if (expanded.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
+ {
+ if (!System.IO.File.Exists(expanded))
+ {
+ errors.Add($"File not found: {raw}");
+ continue;
+ }
+ dirs.Add(Path.GetDirectoryName(Path.GetFullPath(expanded))!);
+ }
+ else if (Directory.Exists(expanded))
+ {
+ var found = Discover(expanded);
+ if (found.Count == 0)
+ errors.Add($"No .csproj projects found in: {raw}");
+ else
+ dirs.AddRange(found);
+ }
+ else
+ {
+ errors.Add($"Path not found: {raw}");
+ }
+ }
+
+ var deduped = dirs.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ return (deduped, errors);
+ }
+
+ ///
+ /// Returns distinct project root directories under .
+ /// Each returned path is the directory containing a .csproj file.
+ ///
+ public static IReadOnlyList Discover(string root)
+ {
+ var fullRoot = Path.GetFullPath(ExpandPath(root));
+ if (!Directory.Exists(fullRoot))
+ return [];
+
+ return Directory.EnumerateFiles(fullRoot, "*.csproj", SearchOption.AllDirectories)
+ .Where(path => !ShouldSkip(path))
+ .Select(path => Path.GetDirectoryName(path)!)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ internal static string ExpandPath(string path) =>
+ path.StartsWith('~')
+ ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[1..].TrimStart('/'))
+ : Environment.ExpandEnvironmentVariables(path);
+
+ private static bool ShouldSkip(string path)
+ {
+ var sep = Path.DirectorySeparatorChar;
+ return SkipDirs.Any(dir =>
+ path.Contains($"{sep}{dir}{sep}", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ConflictRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ConflictRenderer.cs
new file mode 100644
index 0000000..168c9f3
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ConflictRenderer.cs
@@ -0,0 +1,59 @@
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+
+///
+/// Renders results as a Spectre.Console panel containing a table.
+///
+public static class ConflictRenderer
+{
+ public static void Render(IReadOnlyList conflicts, string projectName, PathStyle pathStyle = PathStyle.Relative)
+ {
+ if (conflicts.Count == 0)
+ return;
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Default)
+ .AddColumn("Key")
+ .AddColumn("Value")
+ .AddColumn("Locations");
+
+ foreach (var conflict in conflicts)
+ {
+ for (var i = 0; i < conflict.Values.Count; i++)
+ {
+ var value = conflict.Values[i];
+ var locations = string.Join(", ", value.Sites.Select(s =>
+ s.DisplayMarkup(pathStyle)));
+
+ table.AddRow(
+ i == 0 ? $"[bold]{Markup.Escape(conflict.Key)}[/]" : "",
+ $"[dim]\"{Markup.Escape(FormatSourceText(value.SourceText))}\"[/]",
+ locations);
+ }
+
+ table.AddEmptyRow();
+ }
+
+ var panel = new Panel(table)
+ .Header($"[yellow]β {conflicts.Count} conflicting key(s) in {Markup.Escape(projectName)}[/]")
+ .BorderColor(Color.Yellow)
+ .Padding(1, 0);
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(panel);
+ }
+
+ private static string FormatSourceText(TranslationSourceText text) => text switch
+ {
+ SingularText s => s.Value,
+ PluralText p => string.Join(" | ",
+ new[] { ("zero", p.Zero), ("one", p.One), ("two", p.Two), ("few", p.Few), ("many", p.Many), ("other", (string?)p.Other) }
+ .Where(t => t.Item2 is not null)
+ .Select(t => $"{t.Item1}: {t.Item2}")),
+ SelectText s => string.Join(" | ", s.Cases.Select(c => $"{c.Key}: {c.Value}")),
+ _ => "(unknown)"
+ };
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ExtractedCallRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ExtractedCallRenderer.cs
new file mode 100644
index 0000000..aec1c90
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ExtractedCallRenderer.cs
@@ -0,0 +1,83 @@
+using BlazorLocalization.Extractor.Adapters.Roslyn;
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+
+///
+/// Renders results as a Spectre.Console table.
+/// Opt-in section of the inspect command (--show-extracted-calls).
+///
+internal static class ExtractedCallRenderer
+{
+ public static void Render(IReadOnlyList calls, string? projectName = null, PathStyle pathStyle = PathStyle.Relative)
+ {
+ var header = projectName is not null
+ ? $"[blue]{Markup.Escape(projectName)}[/] [dim]β Extracted Calls ({calls.Count})[/]"
+ : $"[blue]Extracted Calls[/] [dim]({calls.Count})[/]";
+ AnsiConsole.Write(new Rule(header).LeftJustified());
+ AnsiConsole.WriteLine();
+
+ if (calls.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[dim]No IStringLocalizer usage detected.[/]");
+ return;
+ }
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Default)
+ .AddColumn(new TableColumn("#").RightAligned())
+ .AddColumn("Type.Method")
+ .AddColumn("Kind")
+ .AddColumn("Location")
+ .AddColumn("Arguments");
+
+ for (var i = 0; i < calls.Count; i++)
+ {
+ var call = calls[i];
+ var method = $"{Markup.Escape(call.ContainingTypeName)}.{Markup.Escape(call.MethodName)}";
+ var kind = call.CallKind.ToString();
+
+ var location = call.File.DisplayMarkup(pathStyle, call.Line);
+
+ var argsText = FormatArguments(call.Arguments);
+
+ if (call.Chain is { Count: > 0 })
+ argsText += "\n" + FormatChain(call.Chain);
+
+ table.AddRow(
+ $"[dim]{i + 1}[/]",
+ $"[bold]{method}[/]",
+ $"[dim]{Markup.Escape(kind)}[/]",
+ location,
+ argsText);
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ private static string FormatArguments(IReadOnlyList args)
+ {
+ var parts = new List();
+ foreach (var arg in args)
+ {
+ var name = arg.ParameterName ?? $"arg{arg.Position}";
+ var value = arg.Value.Display(40);
+ parts.Add($"[dim]{Markup.Escape(name)}[/]=[bold]{Markup.Escape(value)}[/]");
+ }
+ return string.Join("\n", parts);
+ }
+
+ private static string FormatChain(IReadOnlyList chain)
+ {
+ var lines = new List();
+ foreach (var link in chain)
+ {
+ var argCount = link.Arguments.Count;
+ var argText = argCount > 0 ? $"{argCount} arg(s)" : "";
+ lines.Add($"[dim] .{Markup.Escape(link.MethodName)}({Markup.Escape(argText)})[/]");
+ }
+ return string.Join("\n", lines);
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/JsonRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/JsonRenderer.cs
new file mode 100644
index 0000000..9cc8f2a
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/JsonRenderer.cs
@@ -0,0 +1,208 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Unicode;
+using BlazorLocalization.Extractor.Application;
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+
+///
+/// Renders scan results as JSON to stdout for piped/machine-readable output.
+///
+internal static class JsonRenderer
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
+ };
+
+ public static void RenderInspect(
+ string projectName,
+ IReadOnlyList entries,
+ IReadOnlyList conflicts,
+ IReadOnlyList invalidEntries,
+ HashSet? localeFilter,
+ PathStyle pathStyle = PathStyle.Relative)
+ {
+ var resxEntries = entries
+ .Where(e => e.Definitions.Any(d => d.File.IsResx))
+ .ToList();
+ var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter);
+ var sourceKeys = resxEntries.Select(e => e.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ Console.WriteLine(JsonSerializer.Serialize(new
+ {
+ project = projectName,
+
+ // 1. Translation Entries
+ translationEntries = entries.Select(e => new
+ {
+ key = e.IsKeyLiteral ? e.Key : null,
+ isDynamic = !e.IsKeyLiteral ? true : (bool?)null,
+ usage = e.References
+ .Select(r => new { file = r.File.Display(pathStyle), line = r.Line })
+ .ToList() is { Count: > 0 } refs ? refs : null,
+ form = TranslationFormExtensions.From(e.SourceText)?.ToString(),
+ sourceText = MapSourceText(e.SourceText),
+ source = GetSource(e, pathStyle),
+ status = e.Status.ToString(),
+ locales = e.InlineTranslations is { Count: > 0 }
+ ? e.InlineTranslations.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList()
+ : null
+ }),
+
+ // 2. Conflicts
+ conflicts = conflicts.Count > 0 ? conflicts.Select(MapConflict) : null,
+
+ // 3. .resx Files
+ resxFiles = resxEntries.Count > 0 ? new
+ {
+ sourceEntries = resxEntries.Count,
+ localeCount = allLocales.Count + 1,
+ unreferenced = resxEntries.Count(e => e.Status == TranslationStatus.Review),
+ locales = allLocales.Select(locale =>
+ {
+ var count = resxEntries.Count(e => e.InlineTranslations?.ContainsKey(locale) == true);
+ var missing = resxEntries.Count - count;
+ var unique = resxEntries
+ .Where(e => e.InlineTranslations?.ContainsKey(locale) == true)
+ .Count(e => !sourceKeys.Contains(e.Key));
+ return new
+ {
+ locale,
+ entries = count,
+ coverage = resxEntries.Count > 0 ? Math.Round((double)count / resxEntries.Count * 100, 1) : 0,
+ missing = missing > 0 ? missing : (int?)null,
+ unique = unique > 0 ? unique : (int?)null
+ };
+ })
+ } : null,
+
+ // 4. Invalid entries
+ invalidEntries = invalidEntries.Count > 0 ? invalidEntries.Select(e => new
+ {
+ key = e.Key,
+ reason = e.Reason,
+ locations = e.Sites.Select(s => new { file = s.File.Display(pathStyle), line = s.Line })
+ }) : null,
+
+ // 5. Cross-reference
+ crossReference = new
+ {
+ resolved = entries.Count(e => e.Status == TranslationStatus.Resolved),
+ review = entries.Count(e => e.Status == TranslationStatus.Review),
+ missing = entries.Count(e => e.Status == TranslationStatus.Missing),
+ entries = entries.Select(e => new
+ {
+ key = e.Key,
+ status = e.Status.ToString()
+ })
+ }
+ }, Options));
+ }
+
+ public static void RenderExtract(
+ string projectName,
+ IReadOnlyList entries,
+ IReadOnlyList conflicts)
+ {
+ Console.WriteLine(JsonSerializer.Serialize(new
+ {
+ project = projectName,
+ entries = entries.Select(MapEntry),
+ conflicts = conflicts.Select(MapConflict)
+ }, Options));
+ }
+
+ private static object MapEntry(MergedTranslation entry) => new
+ {
+ key = entry.Key,
+ sourceText = MapSourceText(entry.SourceText),
+ isKeyLiteral = entry.IsKeyLiteral,
+ inlineTranslations = entry.InlineTranslations?.ToDictionary(
+ kvp => kvp.Key,
+ kvp => MapSourceText(kvp.Value)),
+ definitions = entry.Definitions.Select(d => new
+ {
+ filePath = d.File.Display(PathStyle.Relative),
+ line = d.Line,
+ projectName = d.File.ProjectName
+ }),
+ references = entry.References.Select(r => new
+ {
+ filePath = r.File.Display(PathStyle.Relative),
+ line = r.Line,
+ projectName = r.File.ProjectName
+ })
+ };
+
+ private static object? MapSourceText(TranslationSourceText? text) => text switch
+ {
+ SingularText s => new { type = "singular", value = s.Value },
+ PluralText p => new
+ {
+ type = "plural",
+ other = p.Other,
+ zero = p.Zero,
+ one = p.One,
+ two = p.Two,
+ few = p.Few,
+ many = p.Many,
+ exactMatches = p.ExactMatches?.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value),
+ isOrdinal = p.IsOrdinal ? true : (bool?)null
+ },
+ SelectText s => new
+ {
+ type = "select",
+ cases = s.Cases,
+ otherwise = s.Otherwise
+ },
+ SelectPluralText sp => new
+ {
+ type = "selectPlural",
+ cases = sp.Cases.ToDictionary(kvp => kvp.Key, kvp => MapSourceText(kvp.Value)),
+ otherwise = sp.Otherwise is not null ? MapSourceText(sp.Otherwise) : null
+ },
+ _ => null
+ };
+
+ private static object MapConflict(KeyConflict conflict) => new
+ {
+ key = conflict.Key,
+ values = conflict.Values.Select(v => new
+ {
+ sourceText = MapSourceText(v.SourceText),
+ sites = v.Sites.Select(s => new
+ {
+ filePath = s.File.Display(PathStyle.Relative),
+ line = s.Line
+ })
+ })
+ };
+
+ private static object? GetSource(MergedTranslation entry, PathStyle pathStyle)
+ {
+ if (entry.Definitions.Count == 0) return null;
+
+ var def = entry.Definitions[0];
+ var kind = def.Kind switch
+ {
+ DefinitionKind.InlineTranslation => ".Translation()",
+ DefinitionKind.ReusableDefinition => def.Context?.Split('.').LastOrDefault() is { } name ? $"{name}()" : "Definition()",
+ DefinitionKind.EnumAttribute => "[Translation]",
+ DefinitionKind.ResourceFile => def.File.FileName,
+ _ => null
+ };
+
+ return new
+ {
+ kind,
+ file = def.File.Display(pathStyle),
+ line = def.Line
+ };
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/PoLimitationRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/PoLimitationRenderer.cs
new file mode 100644
index 0000000..4384b40
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/PoLimitationRenderer.cs
@@ -0,0 +1,36 @@
+using BlazorLocalization.Extractor.Adapters.Export;
+using Spectre.Console;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+
+///
+/// Renders PO format limitations as a Spectre.Console panel containing a table.
+///
+public static class PoLimitationRenderer
+{
+ public static void Render(IReadOnlyList limitations, string projectName)
+ {
+ if (limitations.Count == 0)
+ return;
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Default)
+ .AddColumn("Key")
+ .AddColumn("Limitation");
+
+ foreach (var limitation in limitations)
+ table.AddRow(
+ $"[bold]{Markup.Escape(limitation.Key)}[/]",
+ $"[dim]{Markup.Escape(limitation.Limitation)}[/]");
+
+ var panel = new Panel(table)
+ .Header($"[yellow]β {limitations.Count} PO format limitation(s) in {Markup.Escape(projectName)}[/]")
+ .BorderColor(Color.Yellow)
+ .Padding(1, 0);
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(panel);
+ AnsiConsole.MarkupLine("[dim] π‘ Consider i18next JSON or generic JSON for full plural fidelity.[/]");
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/SourceFileMarkup.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/SourceFileMarkup.cs
new file mode 100644
index 0000000..39d69e9
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/SourceFileMarkup.cs
@@ -0,0 +1,25 @@
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+
+///
+/// Spectre.Console markup helpers for source file paths.
+/// Generates OSC 8 clickable file:/// links for terminals that support them.
+///
+internal static class SourceFileMarkup
+{
+ public static string DisplayMarkup(this SourceFilePath file, PathStyle style, int? line = null)
+ {
+ var display = file.Display(style);
+ var displayWithLine = line is not null ? $"{display}:{line}" : display;
+ var uri = $"file:///{file.AbsolutePath.TrimStart('/')}";
+ return $"[link={Markup.Escape(uri)}][cyan]{Markup.Escape(displayWithLine)}[/][/]";
+ }
+
+ public static string DisplayMarkup(this DefinitionSite site, PathStyle style) =>
+ site.File.DisplayMarkup(style, site.Line);
+
+ public static string DisplayMarkup(this ReferenceSite site, PathStyle style) =>
+ site.File.DisplayMarkup(style, site.Line);
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/TranslationEntryRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/TranslationEntryRenderer.cs
new file mode 100644
index 0000000..ff26db9
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/TranslationEntryRenderer.cs
@@ -0,0 +1,606 @@
+using System.Globalization;
+using BlazorLocalization.Extractor.Application;
+using BlazorLocalization.Extractor.Domain;
+using Spectre.Console;
+
+namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering;
+
+///
+/// Renders inspect output: translation entries, .resx overview, anomalies, warnings, and legend.
+///
+public static class TranslationEntryRenderer
+{
+ // ββ Translation Entries table ββ
+
+ public static void Render(
+ IReadOnlyList entries,
+ string? projectName = null,
+ PathStyle pathStyle = PathStyle.Relative)
+ {
+ AnsiConsole.WriteLine();
+ var header = projectName is not null
+ ? $"[blue]{Markup.Escape(projectName)}[/] [dim]β Translation Entries ({entries.Count})[/]"
+ : $"[blue]Translation Entries[/] [dim]({entries.Count})[/]";
+ AnsiConsole.Write(new Rule(header).LeftJustified());
+ AnsiConsole.WriteLine();
+
+ if (entries.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[dim]No translation entries found.[/]");
+ return;
+ }
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Default)
+ .ShowRowSeparators()
+ .AddColumn("Key")
+ .AddColumn("Usage")
+ .AddColumn("Form")
+ .AddColumn("Source Text")
+ .AddColumn("Source")
+ .AddColumn("Status")
+ .AddColumn("Locales");
+
+ foreach (var entry in entries)
+ {
+ var key = entry.IsKeyLiteral
+ ? $"[bold]{Markup.Escape(entry.Key)}[/]"
+ : $"[dim]β {Markup.Escape(entry.Key)}[/]";
+
+ var usage = FormatUsage(entry, pathStyle);
+ var form = FormatForm(entry.SourceText);
+ var sourceText = FormatSourceText(entry.SourceText);
+ var source = FormatSource(entry, pathStyle);
+ var status = FormatStatus(entry);
+ var locales = FormatLocales(entry);
+
+ table.AddRow(key, usage, form, sourceText, source, status, locales);
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ // ββ Conflicts ββ
+
+ public static void RenderConflicts(IReadOnlyList conflicts, PathStyle pathStyle = PathStyle.Relative)
+ {
+ if (conflicts.Count == 0) return;
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule($"[yellow]β Conflicts ({conflicts.Count})[/]").LeftJustified());
+ AnsiConsole.WriteLine();
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Yellow)
+ .AddColumn("Key")
+ .AddColumn("Source Text")
+ .AddColumn("Locations");
+
+ foreach (var conflict in conflicts)
+ {
+ for (var i = 0; i < conflict.Values.Count; i++)
+ {
+ var value = conflict.Values[i];
+ var locations = string.Join(", ", value.Sites.Select(s =>
+ s.DisplayMarkup(pathStyle)));
+
+ table.AddRow(
+ i == 0 ? $"[bold]{Markup.Escape(conflict.Key)}[/]" : "",
+ $"[dim]\"{Markup.Escape(FormatSourceTextBrief(value.SourceText))}\"[/]",
+ locations);
+ }
+
+ table.AddEmptyRow();
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ // ββ Invalid Entries ββ
+
+ public static void RenderInvalidEntries(IReadOnlyList invalidEntries, PathStyle pathStyle = PathStyle.Relative)
+ {
+ if (invalidEntries.Count == 0) return;
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule($"[red]β Invalid Entries ({invalidEntries.Count})[/]").LeftJustified());
+ AnsiConsole.WriteLine();
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Red)
+ .AddColumn("Location")
+ .AddColumn("Reason");
+
+ foreach (var entry in invalidEntries)
+ {
+ var locations = string.Join(", ", entry.Sites.Select(s =>
+ s.DisplayMarkup(pathStyle)));
+ table.AddRow(locations, $"[red]{Markup.Escape(entry.Reason)}[/]");
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ // ββ .resx Overview ββ
+
+ public static void RenderResxOverview(
+ IReadOnlyList entries,
+ HashSet? localeFilter)
+ {
+ var resxEntries = entries
+ .Where(e => e.Definitions.Any(d => d.File.IsResx))
+ .ToList();
+
+ if (resxEntries.Count == 0) return;
+
+ var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter);
+ var sourceKeyCount = resxEntries.Count;
+ var unreferencedCount = resxEntries.Count(e => e.Status == TranslationStatus.Review);
+
+ AnsiConsole.WriteLine();
+ var localeCount = allLocales.Count + 1; // +1 for source
+ AnsiConsole.Write(new Rule($"[blue].resx Files:[/] [dim]{sourceKeyCount} entries across {localeCount} locales[/]").LeftJustified());
+ AnsiConsole.WriteLine();
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Default)
+ .AddColumn("Locale")
+ .AddColumn(new TableColumn("Entries").RightAligned())
+ .AddColumn("Coverage")
+ .AddColumn(new TableColumn("Unique").RightAligned())
+ .AddColumn(new TableColumn("Unreferenced").RightAligned())
+ .AddColumn("Status");
+
+ // Source locale row
+ table.AddRow(
+ "[bold](source)[/]",
+ sourceKeyCount.ToString(),
+ "[dim]β[/]",
+ "[dim]β[/]",
+ unreferencedCount > 0 ? $"[yellow]{unreferencedCount}[/]" : "[dim]β[/]",
+ "[dim]Source locale[/]");
+
+ // Per-locale rows
+ foreach (var locale in allLocales)
+ {
+ var count = resxEntries.Count(e => e.InlineTranslations?.ContainsKey(locale) == true);
+ var coverage = sourceKeyCount > 0 ? (double)count / sourceKeyCount * 100 : 0;
+ var missing = sourceKeyCount - count;
+
+ var coverageStr = $"{coverage:F1}%";
+ var coverageColor = coverage >= 100 ? "green" : coverage >= 90 ? "yellow" : "red";
+
+ var statusParts = new List();
+ if (missing > 0) statusParts.Add($"{missing} missing");
+ var status = statusParts.Count > 0 ? string.Join(", ", statusParts) : "OK";
+ var statusColor = status == "OK" ? "green" : "yellow";
+
+ table.AddRow(
+ $"[bold]{Markup.Escape(GetLocaleDisplayName(locale))}[/]",
+ count.ToString(),
+ $"[{coverageColor}]{coverageStr}[/]",
+ "[dim]0[/]",
+ "[dim]β[/]",
+ $"[{statusColor}]{Markup.Escape(status)}[/]");
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ // ββ Anomalies ββ
+
+ public static void RenderAnomalies(
+ IReadOnlyList entries,
+ HashSet? localeFilter)
+ {
+ // Find resx-sourced entries where a locale exists but source key might be orphaned
+ var resxEntries = entries
+ .Where(e => e.Definitions.Any(d => d.File.IsResx))
+ .ToList();
+
+ if (resxEntries.Count == 0) return;
+
+ var sourceKeys = resxEntries.Select(e => e.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter);
+
+ var anomalies = new List<(string Locale, string Key, string File)>();
+ foreach (var locale in allLocales)
+ {
+ foreach (var entry in resxEntries)
+ {
+ if (entry.InlineTranslations?.ContainsKey(locale) != true) continue;
+ if (sourceKeys.Contains(entry.Key)) continue;
+
+ var file = entry.Definitions
+ .FirstOrDefault(d => d.File.IsResx)
+ ?.File.FileName ?? "β";
+ anomalies.Add((locale, entry.Key, file));
+ }
+ }
+
+ if (anomalies.Count == 0) return;
+
+ var localeCount = anomalies.Select(a => a.Locale).Distinct(StringComparer.OrdinalIgnoreCase).Count();
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule($"[yellow]β Anomalies ({anomalies.Count} unique keys across {localeCount} locales)[/]").LeftJustified());
+ AnsiConsole.WriteLine();
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Yellow)
+ .AddColumn("Locale")
+ .AddColumn("Key")
+ .AddColumn("File");
+
+ foreach (var (locale, key, file) in anomalies)
+ table.AddRow(
+ $"[bold]{Markup.Escape(locale)}[/]",
+ $"[bold]{Markup.Escape(key)}[/]",
+ $"[cyan]{Markup.Escape(file)}[/]");
+
+ AnsiConsole.Write(table);
+ }
+
+ // ββ Per-locale RESX tables (--show-resx-locales) ββ
+
+ public static void RenderResxLocales(
+ IReadOnlyList entries,
+ HashSet? localeFilter)
+ {
+ var resxEntries = entries
+ .Where(e => e.Definitions.Any(d => d.File.IsResx))
+ .ToList();
+
+ if (resxEntries.Count == 0) return;
+
+ var unreferencedCount = resxEntries.Count(e => e.Status == TranslationStatus.Review);
+
+ // Source locale table
+ AnsiConsole.WriteLine();
+ var sourceHeader = $"[blue].resx Files (source locale)[/] [dim]β {resxEntries.Count} entries";
+ if (unreferencedCount > 0) sourceHeader += $", {unreferencedCount} unreferenced";
+ sourceHeader += "[/]";
+ AnsiConsole.Write(new Rule(sourceHeader).LeftJustified());
+ AnsiConsole.WriteLine();
+
+ var sourceTable = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Default)
+ .AddColumn("Key")
+ .AddColumn("Value")
+ .AddColumn("File")
+ .AddColumn("Status");
+
+ foreach (var entry in resxEntries)
+ {
+ var value = FormatSourceTextBrief(entry.SourceText);
+ var file = entry.Definitions
+ .FirstOrDefault(d => d.File.IsResx)
+ ?.File.FileName ?? "β";
+
+ var status = entry.Status == TranslationStatus.Review
+ ? "[yellow]Review[/]"
+ : "[green]Resolved[/]";
+
+ sourceTable.AddRow(
+ $"[bold]{Markup.Escape(entry.Key)}[/]",
+ $"[dim]{Markup.Escape(value)}[/]",
+ $"[cyan]{Markup.Escape(file)}[/]",
+ status);
+ }
+
+ AnsiConsole.Write(sourceTable);
+
+ // Per non-source locale tables
+ var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter);
+ var sourceKeys = resxEntries.Select(e => e.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var locale in allLocales)
+ {
+ var localeEntries = resxEntries
+ .Where(e => e.InlineTranslations?.ContainsKey(locale) == true)
+ .ToList();
+
+ if (localeEntries.Count == 0) continue;
+
+ var missing = resxEntries.Count - localeEntries.Count;
+ var uniqueCount = localeEntries.Count(e => !sourceKeys.Contains(e.Key));
+
+ var displayName = GetLocaleDisplayName(locale);
+ var localeHeader = $"[blue].resx Files ({Markup.Escape(displayName)})[/] [dim]β {localeEntries.Count} entries";
+ if (missing > 0) localeHeader += $", {missing} missing";
+ if (uniqueCount > 0) localeHeader += $", {uniqueCount} unique";
+ localeHeader += "[/]";
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule(localeHeader).LeftJustified());
+ AnsiConsole.WriteLine();
+
+ var localeTable = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Default)
+ .AddColumn("Key")
+ .AddColumn("Value")
+ .AddColumn("File")
+ .AddColumn("Status");
+
+ foreach (var entry in localeEntries)
+ {
+ var localeText = entry.InlineTranslations![locale];
+ var value = FormatSourceTextBrief(localeText);
+ var file = entry.Definitions
+ .FirstOrDefault(d => d.File.IsResx)
+ ?.File.FileName ?? "β";
+
+ var isUnique = !sourceKeys.Contains(entry.Key);
+ var status = isUnique ? "[yellow]Unique[/]" : "[green]Resolved[/]";
+
+ localeTable.AddRow(
+ $"[bold]{Markup.Escape(entry.Key)}[/]",
+ $"[dim]{Markup.Escape(value)}[/]",
+ $"[cyan]{Markup.Escape(file)}[/]",
+ status);
+ }
+
+ AnsiConsole.Write(localeTable);
+ }
+ }
+
+ // ββ Cross-Reference Summary ββ
+
+ public static void RenderCrossReferenceSummary(IReadOnlyList entries, string? projectName = null)
+ {
+ AnsiConsole.WriteLine();
+
+ var resolved = entries.Count(e => e.Status == TranslationStatus.Resolved);
+ var review = entries.Count(e => e.Status == TranslationStatus.Review);
+ var missing = entries.Count(e => e.Status == TranslationStatus.Missing);
+
+ var parts = new List { $"[green]{resolved} resolved[/]" };
+ if (review > 0) parts.Add($"[yellow]{review} review[/]");
+ if (missing > 0) parts.Add($"[red]{missing} missing[/]");
+
+ AnsiConsole.MarkupLine($" Cross-reference: {string.Join(", ", parts)}");
+ }
+
+ // ββ Legend ββ
+
+ public static void RenderLegend(
+ IReadOnlyList entries,
+ bool hasResxFiles,
+ bool showedExtractedCalls,
+ bool hasInvalidEntries = false)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule("[dim]Legend[/]").LeftJustified());
+ AnsiConsole.WriteLine();
+
+ // Form β only show forms that actually appear
+ var forms = entries.Select(e => TranslationFormExtensions.From(e.SourceText)).ToHashSet();
+ AnsiConsole.MarkupLine(" [dim]Form[/]");
+ if (forms.Contains(TranslationForm.Simple))
+ AnsiConsole.MarkupLine(" [bold]Simple[/] Single message");
+ if (forms.Contains(TranslationForm.Plural))
+ AnsiConsole.MarkupLine(" [bold]Plural[/] Branching by count (.One/.Other/...)");
+ if (forms.Contains(TranslationForm.Ordinal))
+ AnsiConsole.MarkupLine(" [bold]Ordinal[/] Branching by ordinal rank (1st, 2nd, ...)");
+ if (forms.Contains(TranslationForm.Select))
+ AnsiConsole.MarkupLine(" [bold]Select[/] Branching by category (.When/.Otherwise)");
+ if (forms.Contains(TranslationForm.SelectPlural))
+ AnsiConsole.MarkupLine(" [bold]Select+Plural[/] Combined category + count branching");
+
+ // Status β only show states that actually appear
+ var hasMissing = entries.Any(e => e.Status == TranslationStatus.Missing);
+ var hasReview = entries.Any(e => e.Status == TranslationStatus.Review);
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine(" [dim]Status[/]");
+ AnsiConsole.MarkupLine(" [green]Resolved[/] Fully verified β source text and code usage found");
+ if (hasReview)
+ AnsiConsole.MarkupLine(" [yellow]Review[/] Needs manual check\n [dim]β = definition found but no usage detected (possibly unused)\n β = dynamic key expression (actual key determined at runtime)[/]");
+ if (hasMissing)
+ AnsiConsole.MarkupLine(" [red]Missing[/] Code references this key but no source text found");
+
+ // Source β only show types that actually appear
+ var kinds = entries.SelectMany(e => e.Definitions).Select(d => d.Kind).ToHashSet();
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine(" [dim]Source[/]");
+ if (kinds.Contains(DefinitionKind.InlineTranslation))
+ AnsiConsole.MarkupLine(" [bold].Translation()[/] Source text defined inline in code");
+ if (kinds.Contains(DefinitionKind.ReusableDefinition))
+ AnsiConsole.MarkupLine(" [bold]DefineXxx()[/] Reusable definition (DefineSimple, DefinePlural, ...)");
+ if (kinds.Contains(DefinitionKind.EnumAttribute))
+ AnsiConsole.MarkupLine(" [bold][[Translation]][/] Enum member attribute");
+ if (kinds.Contains(DefinitionKind.ResourceFile))
+ AnsiConsole.MarkupLine(" [bold].resx[/] .resx resource file");
+
+ // .resx Files β only when RESX files were found
+ if (hasResxFiles)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine(" [dim].resx Files[/]");
+ AnsiConsole.MarkupLine(" [bold]Coverage[/] % of source locale keys present in this locale");
+ AnsiConsole.MarkupLine(" [bold]Unique[/] Keys in this locale that don't exist in the source locale");
+ AnsiConsole.MarkupLine(" [bold]Unreferenced[/] Source .resx keys with no usage found in your code");
+ }
+
+ // Extraction β only when the extracted calls table was shown
+ if (showedExtractedCalls)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine(" [dim]Extraction[/]");
+ AnsiConsole.MarkupLine(" [green]Detected[/] blazor-loc detected and classified the call");
+ }
+
+ // Invalid β only when invalid entries were detected
+ if (hasInvalidEntries)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine(" [dim]Invalid[/]");
+ AnsiConsole.MarkupLine(" [red]Empty key[/] Empty Localizer indexer or .Translate(key:\"\") β compiles but does nothing");
+ }
+ }
+
+ // ββ Formatting helpers ββ
+
+ private static string FormatUsage(MergedTranslation entry, PathStyle pathStyle)
+ {
+ var lines = entry.References
+ .Select(r => r.DisplayMarkup(pathStyle))
+ .ToList();
+
+ return lines.Count > 0 ? string.Join("\n", lines) : "[dim]β[/]";
+ }
+
+ private static string FormatForm(TranslationSourceText? sourceText)
+ {
+ var form = TranslationFormExtensions.From(sourceText);
+ return form switch
+ {
+ TranslationForm.Simple => "[green]Simple[/]",
+ TranslationForm.Plural => "[blue]Plural[/]",
+ TranslationForm.Ordinal => "[blue]Ordinal[/]",
+ TranslationForm.Select => "[magenta]Select[/]",
+ TranslationForm.SelectPlural => "[magenta]Select+Plural[/]",
+ null => "[dim]β[/]",
+ _ => "[dim]?[/]"
+ };
+ }
+
+ private static string FormatSource(MergedTranslation entry, PathStyle pathStyle)
+ {
+ if (entry.Definitions.Count == 0) return "[dim]β[/]";
+
+ var def = entry.Definitions[0]; // first-seen wins (matches SourceText resolution)
+ var label = def.Kind switch
+ {
+ DefinitionKind.InlineTranslation => "[dim].Translation()[/]",
+ DefinitionKind.ReusableDefinition => $"[dim]{Markup.Escape(FormatDefinitionLabel(def.Context))}[/]",
+ DefinitionKind.EnumAttribute => "[dim][[Translation]][/]",
+ DefinitionKind.ResourceFile => $"[dim]{Markup.Escape(def.File.FileName)}[/]",
+ _ => "[dim]β[/]"
+ };
+
+ // ResourceFile: the filename IS the location β no need for a second line
+ if (def.Kind == DefinitionKind.ResourceFile)
+ return label;
+
+ return $"{label}\n {def.DisplayMarkup(pathStyle)}";
+ }
+
+ private static string FormatDefinitionLabel(string? context)
+ {
+ // Context is e.g. "TranslationDefinitions.DefineSimple" β extract "DefineSimple()"
+ if (context is null) return "Definition()";
+ var dot = context.LastIndexOf('.');
+ var name = dot >= 0 ? context[(dot + 1)..] : context;
+ return $"{name}()";
+ }
+
+ private static string FormatStatus(MergedTranslation entry) =>
+ entry.Status switch
+ {
+ TranslationStatus.Review => "[yellow]Review[/]",
+ TranslationStatus.Missing => "[red]Missing[/]",
+ _ => "[green]Resolved[/]"
+ };
+
+ private static string FormatLocales(MergedTranslation entry)
+ {
+ if (entry.InlineTranslations is not { Count: > 0 })
+ return "[dim]β[/]";
+
+ return string.Join(", ", entry.InlineTranslations.Keys.Order(StringComparer.OrdinalIgnoreCase));
+ }
+
+ private static string FormatSourceText(TranslationSourceText? text)
+ {
+ switch (text)
+ {
+ case SingularText s:
+ return $"\"{Markup.Escape(s.Value)}\"";
+
+ case PluralText p:
+ var parts = new List();
+ if (p.Zero is not null) parts.Add($"zero: \"{Markup.Escape(p.Zero)}\"");
+ if (p.One is not null) parts.Add($"one: \"{Markup.Escape(p.One)}\"");
+ if (p.Two is not null) parts.Add($"two: \"{Markup.Escape(p.Two)}\"");
+ if (p.Few is not null) parts.Add($"few: \"{Markup.Escape(p.Few)}\"");
+ if (p.Many is not null) parts.Add($"many: \"{Markup.Escape(p.Many)}\"");
+ parts.Add($"other: \"{Markup.Escape(p.Other)}\"");
+ if (p.ExactMatches is { Count: > 0 })
+ foreach (var (v, m) in p.ExactMatches)
+ parts.Add($"exactly({v}): \"{Markup.Escape(m)}\"");
+ return string.Join("\n", parts);
+
+ case SelectText s:
+ var selectParts = s.Cases.Select(c => $"{Markup.Escape(c.Key)}: \"{Markup.Escape(c.Value)}\"").ToList();
+ if (s.Otherwise is not null)
+ selectParts.Add($"otherwise: \"{Markup.Escape(s.Otherwise)}\"");
+ return string.Join("\n", selectParts);
+
+ case SelectPluralText sp:
+ var spParts = new List();
+ foreach (var (caseValue, plural) in sp.Cases)
+ {
+ spParts.Add($"[magenta]{Markup.Escape(caseValue)}:[/]");
+ spParts.Add(FormatPluralInline(plural));
+ }
+ if (sp.Otherwise is not null)
+ {
+ spParts.Add("[magenta]otherwise:[/]");
+ spParts.Add(FormatPluralInline(sp.Otherwise));
+ }
+ return string.Join("\n", spParts);
+
+ case null:
+ return "[dim]β[/]";
+
+ default:
+ return "[dim]?[/]";
+ }
+ }
+
+ private static string FormatSourceTextBrief(TranslationSourceText? text) =>
+ text switch
+ {
+ SingularText s => s.Value,
+ PluralText p => p.One ?? p.Other,
+ SelectText s => s.Otherwise ?? s.Cases.Values.FirstOrDefault() ?? "β",
+ SelectPluralText sp => sp.Otherwise?.One ?? sp.Otherwise?.Other ?? "β",
+ null => "β",
+ _ => "?"
+ };
+
+ private static string FormatPluralInline(PluralText p)
+ {
+ var parts = new List();
+ if (p.Zero is not null) parts.Add($" zero: \"{Markup.Escape(p.Zero)}\"");
+ if (p.One is not null) parts.Add($" one: \"{Markup.Escape(p.One)}\"");
+ if (p.Two is not null) parts.Add($" two: \"{Markup.Escape(p.Two)}\"");
+ if (p.Few is not null) parts.Add($" few: \"{Markup.Escape(p.Few)}\"");
+ if (p.Many is not null) parts.Add($" many: \"{Markup.Escape(p.Many)}\"");
+ parts.Add($" other: \"{Markup.Escape(p.Other)}\"");
+ if (p.ExactMatches is { Count: > 0 })
+ foreach (var (v, m) in p.ExactMatches)
+ parts.Add($" exactly({v}): \"{Markup.Escape(m)}\"");
+ return string.Join("\n", parts);
+ }
+
+ private static string GetLocaleDisplayName(string locale)
+ {
+ try
+ {
+ var culture = CultureInfo.GetCultureInfo(locale, predefinedOnly: true);
+ return culture.EnglishName != locale ? locale : locale;
+ }
+ catch
+ {
+ return locale;
+ }
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/ExporterFactory.cs b/src/BlazorLocalization.Extractor/Adapters/Export/ExporterFactory.cs
new file mode 100644
index 0000000..8a77292
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Export/ExporterFactory.cs
@@ -0,0 +1,25 @@
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Export;
+
+///
+/// Routes an to the matching implementation.
+///
+internal static class ExporterFactory
+{
+ public static ITranslationExporter Create(ExportFormat format) => format switch
+ {
+ ExportFormat.I18Next => new I18NextJsonExporter(),
+ ExportFormat.Json => new GenericJsonExporter(),
+ ExportFormat.Po => new PoExporter(),
+ _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown export format")
+ };
+
+ public static string GetFileExtension(ExportFormat format) => format switch
+ {
+ ExportFormat.I18Next => ".i18next.json",
+ ExportFormat.Json => ".json",
+ ExportFormat.Po => ".po",
+ _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown export format")
+ };
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/GenericJsonExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/GenericJsonExporter.cs
new file mode 100644
index 0000000..af1daf6
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Export/GenericJsonExporter.cs
@@ -0,0 +1,128 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Unicode;
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Export;
+
+///
+/// Exports records as a JSON array β a 1:1 serialization
+/// of the domain model, useful for debugging and downstream tooling that needs full fidelity.
+///
+internal sealed class GenericJsonExporter : ITranslationExporter
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
+ };
+
+ public string Export(IReadOnlyList entries, PathStyle pathStyle)
+ {
+ var dto = entries.Select(e => ToDto(e, pathStyle)).ToList();
+ return JsonSerializer.Serialize(dto, JsonOptions);
+ }
+
+ private static EntryDto ToDto(MergedTranslation entry, PathStyle pathStyle) => new()
+ {
+ Key = entry.Key,
+ SourceText = MapSourceText(entry.SourceText),
+ InlineTranslations = entry.InlineTranslations?.ToDictionary(
+ kvp => kvp.Key,
+ kvp => MapSourceText(kvp.Value)),
+ Definitions = entry.Definitions.Select(d => new DefinitionDto
+ {
+ FilePath = d.File.Display(pathStyle),
+ Line = d.Line,
+ ProjectName = d.File.ProjectName,
+ Context = d.Context
+ }).ToList(),
+ References = entry.References.Select(r => new ReferenceDto
+ {
+ FilePath = r.File.Display(pathStyle),
+ Line = r.Line,
+ ProjectName = r.File.ProjectName
+ }).ToList()
+ };
+
+ private static SourceTextDto? MapSourceText(TranslationSourceText? text) => text switch
+ {
+ SingularText s => new SourceTextDto
+ {
+ Type = "singular",
+ Value = s.Value
+ },
+ PluralText p => new SourceTextDto
+ {
+ Type = "plural",
+ Other = p.Other,
+ Zero = p.Zero,
+ One = p.One,
+ Two = p.Two,
+ Few = p.Few,
+ Many = p.Many,
+ ExactMatches = p.ExactMatches?.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value),
+ IsOrdinal = p.IsOrdinal ? true : null
+ },
+ SelectText s => new SourceTextDto
+ {
+ Type = "select",
+ Cases = s.Cases.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
+ Otherwise = s.Otherwise
+ },
+ SelectPluralText sp => new SourceTextDto
+ {
+ Type = "selectPlural",
+ PluralCases = sp.Cases.ToDictionary(
+ kvp => kvp.Key,
+ kvp => MapSourceText(kvp.Value)),
+ OtherwisePlural = sp.Otherwise is not null ? MapSourceText(sp.Otherwise) : null
+ },
+ _ => null
+ };
+
+ private sealed class EntryDto
+ {
+ public required string Key { get; init; }
+ public SourceTextDto? SourceText { get; init; }
+ public Dictionary? InlineTranslations { get; init; }
+ public required List Definitions { get; init; }
+ public required List References { get; init; }
+ }
+
+ private sealed class SourceTextDto
+ {
+ public required string Type { get; init; }
+ public string? Value { get; init; }
+ public string? Other { get; init; }
+ public string? Zero { get; init; }
+ public string? One { get; init; }
+ public string? Two { get; init; }
+ public string? Few { get; init; }
+ public string? Many { get; init; }
+ public Dictionary? ExactMatches { get; init; }
+ public bool? IsOrdinal { get; init; }
+ public Dictionary? Cases { get; init; }
+ public string? Otherwise { get; init; }
+ public Dictionary? PluralCases { get; init; }
+ public SourceTextDto? OtherwisePlural { get; init; }
+ }
+
+ private sealed class DefinitionDto
+ {
+ public required string FilePath { get; init; }
+ public int Line { get; init; }
+ public required string ProjectName { get; init; }
+ public string? Context { get; init; }
+ }
+
+ private sealed class ReferenceDto
+ {
+ public required string FilePath { get; init; }
+ public int Line { get; init; }
+ public required string ProjectName { get; init; }
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/I18NextJsonExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/I18NextJsonExporter.cs
new file mode 100644
index 0000000..da12aea
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Export/I18NextJsonExporter.cs
@@ -0,0 +1,76 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Unicode;
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Export;
+
+///
+/// Exports translations as i18next JSON for Crowdin upload.
+/// Plural keys use the _one / _other suffix convention.
+/// Key-only entries (no source text) emit an empty string value.
+///
+internal sealed class I18NextJsonExporter : ITranslationExporter
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
+ };
+
+ public string Export(IReadOnlyList entries, PathStyle pathStyle)
+ {
+ var dict = new Dictionary();
+
+ foreach (var entry in entries)
+ EmitEntry(dict, entry.Key, entry.SourceText);
+
+ return JsonSerializer.Serialize(dict, JsonOptions);
+ }
+
+ private static void EmitEntry(Dictionary dict, string key, TranslationSourceText? sourceText)
+ {
+ switch (sourceText)
+ {
+ case PluralText p:
+ EmitPluralText(dict, key, p);
+ break;
+
+ case SelectText s:
+ foreach (var (caseValue, message) in s.Cases)
+ dict[$"{key}_{caseValue}"] = message;
+ if (s.Otherwise is not null)
+ dict[key] = s.Otherwise;
+ break;
+
+ case SelectPluralText sp:
+ foreach (var (caseValue, plural) in sp.Cases)
+ EmitPluralText(dict, $"{key}_{caseValue}", plural);
+ if (sp.Otherwise is not null)
+ EmitPluralText(dict, key, sp.Otherwise);
+ break;
+
+ case SingularText s:
+ dict[key] = s.Value;
+ break;
+
+ default:
+ dict[key] = "";
+ break;
+ }
+ }
+
+ private static void EmitPluralText(Dictionary dict, string key, PluralText p)
+ {
+ if (p.Zero is not null) dict[$"{key}_zero"] = p.Zero;
+ if (p.One is not null) dict[$"{key}_one"] = p.One;
+ if (p.Two is not null) dict[$"{key}_two"] = p.Two;
+ if (p.Few is not null) dict[$"{key}_few"] = p.Few;
+ if (p.Many is not null) dict[$"{key}_many"] = p.Many;
+ dict[$"{key}_other"] = p.Other;
+
+ if (p.ExactMatches is not null)
+ foreach (var (value, message) in p.ExactMatches)
+ dict[$"{key}_exactly_{value}"] = message;
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/ITranslationExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/ITranslationExporter.cs
new file mode 100644
index 0000000..c6812e8
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Export/ITranslationExporter.cs
@@ -0,0 +1,11 @@
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Export;
+
+///
+/// Serializes records into a format-specific string.
+///
+internal interface ITranslationExporter
+{
+ string Export(IReadOnlyList entries, PathStyle pathStyle);
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/PoExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/PoExporter.cs
new file mode 100644
index 0000000..67ff21a
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Export/PoExporter.cs
@@ -0,0 +1,136 @@
+using System.Text;
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Export;
+
+///
+/// Exports translations as a GNU Gettext PO template (.pot).
+/// #: reference comments carry file path and line number from definition sites.
+/// #. extracted comments carry the context (e.g. resx comment or containing type).
+///
+internal sealed class PoExporter : ITranslationExporter
+{
+ public string Export(IReadOnlyList entries, PathStyle pathStyle)
+ {
+ var sb = new StringBuilder();
+ WriteHeader(sb);
+
+ foreach (var entry in entries)
+ {
+ WriteReferenceComments(sb, entry, pathStyle);
+ WriteEntry(sb, entry);
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ private static void WriteHeader(StringBuilder sb)
+ {
+ sb.AppendLine("msgid \"\"");
+ sb.AppendLine("msgstr \"\"");
+ sb.AppendLine("\"MIME-Version: 1.0\\n\"");
+ sb.AppendLine("\"Content-Type: text/plain; charset=UTF-8\\n\"");
+ sb.AppendLine("\"Content-Transfer-Encoding: 8bit\\n\"");
+ sb.AppendLine("\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"");
+ sb.AppendLine("\"X-Crowdin-SourceKey: msgstr\\n\"");
+ sb.AppendLine();
+ }
+
+ private static void WriteReferenceComments(StringBuilder sb, MergedTranslation entry, PathStyle pathStyle)
+ {
+ foreach (var site in entry.Definitions)
+ {
+ sb.AppendLine($"#: {site.File.Display(pathStyle)}:{site.Line}");
+ if (site.Context is not null)
+ sb.AppendLine($"#. {site.Context}");
+ }
+
+ // For reference-only entries (no definitions), include reference sites
+ if (entry.Definitions.Count == 0)
+ {
+ foreach (var site in entry.References)
+ {
+ sb.AppendLine($"#: {site.File.Display(pathStyle)}:{site.Line}");
+ if (site.Context is not null)
+ sb.AppendLine($"#. {site.Context}");
+ }
+ }
+ }
+
+ private static void WriteEntry(StringBuilder sb, MergedTranslation entry)
+ {
+ switch (entry.SourceText)
+ {
+ case PluralText p:
+ WritePluralEntries(sb, entry.Key, p);
+ break;
+
+ case SelectText s:
+ foreach (var (caseValue, message) in s.Cases)
+ {
+ sb.AppendLine($"msgid \"{Escape(entry.Key + "_" + caseValue)}\"");
+ sb.AppendLine($"msgstr \"{Escape(message)}\"");
+ sb.AppendLine();
+ }
+ if (s.Otherwise is not null)
+ {
+ sb.AppendLine($"msgid \"{Escape(entry.Key)}\"");
+ sb.AppendLine($"msgstr \"{Escape(s.Otherwise)}\"");
+ }
+ else
+ {
+ sb.AppendLine($"msgid \"{Escape(entry.Key)}\"");
+ sb.AppendLine("msgstr \"\"");
+ }
+ break;
+
+ case SelectPluralText sp:
+ foreach (var (caseValue, plural) in sp.Cases)
+ {
+ WritePluralEntries(sb, $"{entry.Key}_{caseValue}", plural);
+ sb.AppendLine();
+ }
+ if (sp.Otherwise is not null)
+ WritePluralEntries(sb, entry.Key, sp.Otherwise);
+ break;
+
+ case SingularText s:
+ sb.AppendLine($"msgid \"{Escape(entry.Key)}\"");
+ sb.AppendLine($"msgstr \"{Escape(s.Value)}\"");
+ break;
+
+ default:
+ sb.AppendLine($"msgid \"{Escape(entry.Key)}\"");
+ sb.AppendLine("msgstr \"\"");
+ break;
+ }
+ }
+
+ private static void WritePluralEntries(StringBuilder sb, string key, PluralText p)
+ {
+ if (p.IsOrdinal)
+ sb.AppendLine("#. β οΈ ORDINAL β use ordinal forms (1st, 2nd, 3rd), not cardinal (1, 2, 3)");
+
+ sb.AppendLine($"msgid \"{Escape(key)}\"");
+ sb.AppendLine($"msgid_plural \"{Escape(key)}\"");
+ sb.AppendLine($"msgstr[0] \"{Escape(p.One ?? p.Other)}\"");
+ sb.AppendLine($"msgstr[1] \"{Escape(p.Other)}\"");
+
+ if (p.ExactMatches is not null)
+ {
+ foreach (var (value, message) in p.ExactMatches)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"msgid \"{Escape($"{key}_exactly_{value}")}\"");
+ sb.AppendLine($"msgstr \"{Escape(message)}\"");
+ }
+ }
+ }
+
+ private static string Escape(string text) =>
+ text.Replace("\\", "\\\\")
+ .Replace("\"", "\\\"")
+ .Replace("\r", "\\r")
+ .Replace("\n", "\\n");
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/PoLimitation.cs b/src/BlazorLocalization.Extractor/Adapters/Export/PoLimitation.cs
new file mode 100644
index 0000000..df343bc
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Export/PoLimitation.cs
@@ -0,0 +1,55 @@
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Export;
+
+///
+/// A PO format limitation detected for a specific key.
+/// PO cannot natively represent exact plural matches or ordinal forms.
+///
+public sealed record PoLimitation(string Key, string Limitation)
+{
+ ///
+ /// Scans entries for patterns that PO format cannot represent faithfully.
+ ///
+ public static IReadOnlyList Detect(IReadOnlyList entries)
+ {
+ var results = new List();
+
+ foreach (var entry in entries)
+ {
+ CheckSourceText(entry.Key, entry.SourceText, results);
+ }
+
+ return results;
+ }
+
+ private static void CheckSourceText(string key, TranslationSourceText? text, List results)
+ {
+ switch (text)
+ {
+ case PluralText p:
+ CheckPlural(key, p, results);
+ break;
+
+ case SelectPluralText sp:
+ foreach (var (caseValue, plural) in sp.Cases)
+ CheckPlural($"{key}_{caseValue}", plural, results);
+ if (sp.Otherwise is not null)
+ CheckPlural(key, sp.Otherwise, results);
+ break;
+ }
+ }
+
+ private static void CheckPlural(string key, PluralText p, List results)
+ {
+ if (p.ExactMatches is { Count: > 0 })
+ results.Add(new PoLimitation(key,
+ "Exact plural matches (=0, =1, etc.) are exported as separate keys (_exactly_N). " +
+ "PO translators won't see them as part of the plural form."));
+
+ if (p.IsOrdinal)
+ results.Add(new PoLimitation(key,
+ "Ordinal forms (1st, 2nd, 3rd) are exported with a comment only. " +
+ "PO has no native ordinal concept."));
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Resx/ResxFileParser.cs b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxFileParser.cs
new file mode 100644
index 0000000..a1fdde0
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxFileParser.cs
@@ -0,0 +1,116 @@
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace BlazorLocalization.Extractor.Adapters.Resx;
+
+///
+/// Parses .resx XML files and groups neutral + culture-specific files by base name.
+/// Ported from the existing Extractor's ResxImporter β proven XML parsing logic.
+///
+internal static class ResxFileParser
+{
+ ///
+ /// Parses a single .resx file into a dictionary of key β (value, comment, line).
+ /// Filters out non-string resource entries (embedded files, images, etc.).
+ ///
+ public static IReadOnlyDictionary ParseResx(string resxFilePath)
+ {
+ var doc = XDocument.Load(resxFilePath, LoadOptions.SetLineInfo);
+ var entries = new Dictionary();
+
+ foreach (var data in doc.Descendants("data"))
+ {
+ // Entries with a "type" attribute are embedded resources, not strings
+ if (data.Attribute("type") is not null)
+ continue;
+
+ var key = data.Attribute("name")?.Value;
+ if (key is null)
+ continue;
+
+ var value = data.Element("value")?.Value;
+ if (string.IsNullOrEmpty(value))
+ continue;
+
+ var comment = data.Element("comment")?.Value;
+ var line = (data as IXmlLineInfo)?.LineNumber ?? 0;
+
+ entries[key] = (value, comment, line);
+ }
+
+ return entries;
+ }
+
+ ///
+ /// Enumerates .resx files in a directory, excluding bin/ and obj/ folders.
+ ///
+ public static IEnumerable EnumerateResxFiles(string root) =>
+ Directory.EnumerateFiles(root, "*.resx", SearchOption.AllDirectories)
+ .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
+ && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"))
+ .Order();
+
+ ///
+ /// Groups .resx file paths by base name. For example, Home.resx, Home.da.resx,
+ /// and Home.es-MX.resx all share the base name Home.
+ ///
+ public static Dictionary GroupByBaseName(IEnumerable paths)
+ {
+ var groups = new Dictionary();
+
+ foreach (var path in paths)
+ {
+ var (baseName, culture) = ParseResxFileName(path);
+ if (!groups.TryGetValue(baseName, out var group))
+ {
+ group = new ResxFileGroup();
+ groups[baseName] = group;
+ }
+
+ if (culture is null)
+ group.NeutralPath = path;
+ else
+ group.CulturePaths[culture] = path;
+ }
+
+ return groups;
+ }
+
+ ///
+ /// Parses a .resx file path into its base name and optional culture code.
+ /// /path/Home.resx β (/path/Home, null);
+ /// /path/Home.da.resx β (/path/Home, "da").
+ ///
+ private static (string BaseName, string? Culture) ParseResxFileName(string path)
+ {
+ var dir = Path.GetDirectoryName(path);
+ var fileNameNoResx = Path.GetFileNameWithoutExtension(path);
+ var lastDot = fileNameNoResx.LastIndexOf('.');
+ if (lastDot < 0)
+ return (Combine(dir, fileNameNoResx), null);
+
+ var suffix = fileNameNoResx[(lastDot + 1)..];
+ try
+ {
+ CultureInfo.GetCultureInfo(suffix, predefinedOnly: true);
+ return (Combine(dir, fileNameNoResx[..lastDot]), suffix);
+ }
+ catch
+ {
+ return (Combine(dir, fileNameNoResx), null);
+ }
+
+ static string Combine(string? dir, string name) =>
+ dir is null ? name : Path.Combine(dir, name);
+ }
+
+ ///
+ /// Mutable accumulator for grouping neutral and culture-specific .resx files by base name.
+ ///
+ internal sealed class ResxFileGroup
+ {
+ public string? NeutralPath { get; set; }
+ public Dictionary CulturePaths { get; } = new();
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScanner.cs b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScanner.cs
new file mode 100644
index 0000000..457a483
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScanner.cs
@@ -0,0 +1,91 @@
+using BlazorLocalization.Extractor.Domain;
+using BlazorLocalization.Extractor.Ports;
+
+namespace BlazorLocalization.Extractor.Adapters.Resx;
+
+///
+/// Orchestrates a .resx-based scan of a project directory.
+/// Enumerates .resx files, groups by base name, parses XML, and converts to domain types.
+///
+internal static class ResxScanner
+{
+ ///
+ /// Scans a project directory for .resx files and returns a port-compliant .
+ /// Neutral .resx β with source text.
+ /// Culture-specific .resx β inline translations on the same definition.
+ /// Culture-only keys (no neutral) β skipped with a warning.
+ ///
+ public static ResxScannerOutput Scan(string projectDir)
+ {
+ var definitions = new List();
+ var diagnostics = new List();
+
+ var files = ResxFileParser.EnumerateResxFiles(projectDir);
+ var groups = ResxFileParser.GroupByBaseName(files);
+
+ foreach (var (_, group) in groups.OrderBy(g => g.Key, StringComparer.Ordinal))
+ {
+ ImportGroup(group, projectDir, definitions, diagnostics);
+ }
+
+ return new ResxScannerOutput(definitions, diagnostics);
+ }
+
+ private static void ImportGroup(
+ ResxFileParser.ResxFileGroup group,
+ string projectDir,
+ List definitions,
+ List diagnostics)
+ {
+ // Parse neutral file
+ var neutralEntries = group.NeutralPath is not null
+ ? ResxFileParser.ParseResx(group.NeutralPath)
+ : new Dictionary();
+
+ // Parse all culture files
+ var cultureEntries = new Dictionary>();
+ foreach (var (culture, path) in group.CulturePaths)
+ cultureEntries[culture] = ResxFileParser.ParseResx(path);
+
+ // Collect all keys across neutral and culture files
+ var allKeys = new HashSet(neutralEntries.Keys);
+ foreach (var entries in cultureEntries.Values)
+ allKeys.UnionWith(entries.Keys);
+
+ foreach (var key in allKeys.Order(StringComparer.Ordinal))
+ {
+ // Neutral β SourceText + DefinitionSite
+ if (!neutralEntries.TryGetValue(key, out var neutral))
+ {
+ // Culture-only key: no source text available from resx
+ var cultures = cultureEntries
+ .Where(e => e.Value.ContainsKey(key))
+ .Select(e => e.Key);
+ diagnostics.Add(new ScanDiagnostic(
+ DiagnosticLevel.Warning,
+ $"Key \"{key}\" found in culture file(s) [{string.Join(", ", cultures)}] but not in neutral .resx"));
+ continue;
+ }
+
+ var file = new SourceFilePath(group.NeutralPath!, projectDir);
+ var site = new DefinitionSite(file, neutral.Line, DefinitionKind.ResourceFile, neutral.Comment);
+
+ // Culture files β InlineTranslations
+ Dictionary? inlineTranslations = null;
+ foreach (var (culture, entries) in cultureEntries)
+ {
+ if (entries.TryGetValue(key, out var cultureEntry))
+ {
+ inlineTranslations ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
+ inlineTranslations[culture] = new SingularText(cultureEntry.Value);
+ }
+ }
+
+ definitions.Add(new TranslationDefinition(
+ key,
+ new SingularText(neutral.Value),
+ site,
+ inlineTranslations));
+ }
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScannerOutput.cs b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScannerOutput.cs
new file mode 100644
index 0000000..91398be
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScannerOutput.cs
@@ -0,0 +1,15 @@
+using BlazorLocalization.Extractor.Domain;
+using BlazorLocalization.Extractor.Ports;
+
+namespace BlazorLocalization.Extractor.Adapters.Resx;
+
+///
+/// Resx adapter's implementation of .
+/// Produces definitions only β .resx files define source text, they don't reference keys in code.
+///
+internal sealed record ResxScannerOutput(
+ IReadOnlyList Definitions,
+ IReadOnlyList Diagnostics) : IScannerOutput
+{
+ public IReadOnlyList References { get; } = [];
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CSharpFileProvider.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CSharpFileProvider.cs
new file mode 100644
index 0000000..cfb7279
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CSharpFileProvider.cs
@@ -0,0 +1,28 @@
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Enumerates .cs files under a project directory and produces
+/// s ready for .
+/// Skips obj/ and bin/ directories.
+///
+internal static class CSharpFileProvider
+{
+ public static IReadOnlyList GetDocuments(string projectDir)
+ {
+ var docs = new List();
+ foreach (var path in EnumerateSourceFiles(projectDir))
+ {
+ var tree = CSharpSyntaxTree.ParseText(System.IO.File.ReadAllText(path), path: path);
+ docs.Add(new SourceDocument(tree, path, projectDir, LineMap: null));
+ }
+ return docs;
+ }
+
+ private static IEnumerable EnumerateSourceFiles(string root) =>
+ Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)
+ .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
+ && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"))
+ .Order();
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallInterpreter.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallInterpreter.cs
new file mode 100644
index 0000000..0a00f6c
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallInterpreter.cs
@@ -0,0 +1,648 @@
+using BlazorLocalization.Extractor.Domain;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Converts (with its fluent chain) into domain types:
+/// or .
+///
+/// Dispatches by method name on chain links β no BuilderSymbolTable needed.
+/// The builder type was already confirmed by .
+///
+internal static class CallInterpreter
+{
+ private static readonly HashSet PluralCategoryNames =
+ ExtensionsContract.PluralCategoryNames;
+
+ ///
+ /// Classify a scanned call site and produce the appropriate domain type.
+ ///
+ public static (TranslationDefinition? Definition, TranslationReference? Reference) Interpret(
+ ScannedCallSite call, SourceFilePath file)
+ {
+ if (!call.IsDefinition)
+ return InterpretAsReference(call, file);
+
+ return InterpretAsDefinition(call, file);
+ }
+
+ private static (TranslationDefinition?, TranslationReference?) InterpretAsReference(
+ ScannedCallSite call, SourceFilePath file)
+ {
+ // Try common parameter names for the key, then fall back to position 0
+ var keyArg = FindArg(call.Arguments, "key")
+ ?? FindArg(call.Arguments, "name")
+ ?? FindArgByPosition(call.Arguments, 0);
+
+ if (keyArg is null) return (null, null);
+
+ string? key = null;
+ if (keyArg.Value.TryGetString(out var k))
+ key = k;
+ else if (keyArg.Value is OperationValue.Constant { Value: var v })
+ key = v?.ToString();
+
+ if (key is null)
+ {
+ // Translation(definition) and Display(enum) calls use pre-defined keys β
+ // the key comes from DefineXxx/[Translation] definitions, not this call site.
+ // Only indexer/GetString calls with non-literal keys should produce dynamic refs.
+ if (call.MethodName is ExtensionsContract.Translation or ExtensionsContract.Display)
+ return (null, null);
+
+ // Non-literal key β still a reference, just not resolvable
+ var syntax = keyArg.Value switch
+ {
+ OperationValue.SymbolReference { Symbol: var sym } => sym.Name,
+ OperationValue.Unrecognized { Syntax: var s } => s,
+ _ => null
+ };
+
+ if (syntax is not null)
+ {
+ var site = new ReferenceSite(file, call.Line);
+ return (null, new TranslationReference(syntax, false, site));
+ }
+
+ return (null, null);
+ }
+
+ var isLiteral = keyArg.Value.IsLiteral;
+ var context = call.CallKind == CallKind.Indexer ? "IStringLocalizer.this[]" : null;
+ var refSite = new ReferenceSite(file, call.Line, context);
+ return (null, new TranslationReference(key, isLiteral, refSite));
+ }
+
+ private static (TranslationDefinition?, TranslationReference?) InterpretAsDefinition(
+ ScannedCallSite call, SourceFilePath file)
+ {
+ var keyArg = FindArg(call.Arguments, "key");
+ if (keyArg is null || !keyArg.Value.TryGetString(out var key) || key is null)
+ return (null, null);
+
+ var isFactory = call.MethodName.StartsWith(ExtensionsContract.DefinePrefix, StringComparison.Ordinal);
+ var kind = isFactory ? DefinitionKind.ReusableDefinition : DefinitionKind.InlineTranslation;
+ var defSite = new DefinitionSite(file, call.Line, kind, FormatContext(call));
+
+ // Determine builder kind from return type (captured during CallSiteBuilder via chain presence)
+ // If no chain: simple translation with just message
+ // If chain: walk it to determine the form
+ var chain = call.Chain;
+
+ (TranslationDefinition?, TranslationReference?) result = (null, null);
+
+ if (call.MethodName == ExtensionsContract.Translation)
+ {
+ // Check if there's a "message" param β Simple/Singular
+ var messageArg = FindArg(call.Arguments, "message");
+ if (messageArg is not null)
+ result = InterpretSimple(key, messageArg, chain, defSite);
+
+ // Check if BOTH "select" AND "howMany" β SelectPlural (must check before individual)
+ else if (FindArg(call.Arguments, "select") is not null
+ && FindArg(call.Arguments, "howMany") is not null)
+ {
+ var ordinalArg = FindArg(call.Arguments, "ordinal");
+ var isOrdinal = ordinalArg?.Value is OperationValue.Constant { Value: true };
+ result = InterpretSelectPlural(key, isOrdinal, chain, defSite);
+ }
+
+ // Check if there's a "howMany" param β Plural
+ else if (FindArg(call.Arguments, "howMany") is not null)
+ {
+ var ordinalArg = FindArg(call.Arguments, "ordinal");
+ var isOrdinal = ordinalArg?.Value is OperationValue.Constant { Value: true };
+ result = InterpretPlural(key, isOrdinal, chain, defSite);
+ }
+
+ // Check if there's a "select" param β Select
+ else if (FindArg(call.Arguments, "select") is not null)
+ {
+ result = InterpretSelect(key, chain, defSite);
+ }
+ }
+ else if (isFactory)
+ {
+ result = InterpretDefinitionFactory(call, key, chain, defSite);
+ }
+
+ if (result.Item1 is null)
+ return (null, null);
+
+ // InlineTranslation (.Translation() in Razor/code) is genuinely both definition AND usage.
+ // ReusableDefinition (DefineXxx factory) is only a definition β not a usage.
+ if (kind == DefinitionKind.InlineTranslation)
+ {
+ var refSite = new ReferenceSite(file, call.Line);
+ return (result.Item1, new TranslationReference(key, true, refSite));
+ }
+
+ return (result.Item1, null);
+ }
+
+ // βββ Simple ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static (TranslationDefinition?, TranslationReference?) InterpretSimple(
+ string key,
+ ScannedArgument messageArg,
+ IReadOnlyList? chain,
+ DefinitionSite site)
+ {
+ messageArg.Value.TryGetString(out var message);
+ var sourceText = message is not null
+ ? new SingularText(message)
+ : (TranslationSourceText?)null;
+
+ if (sourceText is null)
+ return (null, null);
+
+ var inlines = CollectSimpleInlineTranslations(chain);
+
+ return (new TranslationDefinition(key, sourceText, site, inlines), null);
+ }
+
+ private static IReadOnlyDictionary? CollectSimpleInlineTranslations(
+ IReadOnlyList? chain)
+ {
+ if (chain is null or { Count: 0 }) return null;
+
+ Dictionary? result = null;
+
+ foreach (var link in chain)
+ {
+ if (link.MethodName != "For") continue;
+
+ var locale = FindChainArgString(link, "locale", 0);
+ var msg = FindChainArgString(link, "message", 1);
+
+ if (locale is not null && msg is not null)
+ {
+ result ??= new(StringComparer.OrdinalIgnoreCase);
+ result.TryAdd(locale, new SingularText(msg));
+ }
+ }
+
+ return result;
+ }
+
+ // βββ Plural ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static (TranslationDefinition?, TranslationReference?) InterpretPlural(
+ string key,
+ bool isOrdinal,
+ IReadOnlyList? chain,
+ DefinitionSite site)
+ {
+ var categories = new Dictionary();
+ var exactMatches = new Dictionary();
+ var forSections = new List<(string locale, List links)>();
+ string? currentLocale = null;
+ List? currentForLinks = null;
+
+ if (chain is not null)
+ {
+ foreach (var link in chain)
+ {
+ if (link.MethodName == "For")
+ {
+ if (currentLocale is not null && currentForLinks is not null)
+ forSections.Add((currentLocale, currentForLinks));
+ currentLocale = FindChainArgString(link, "locale", 0);
+ currentForLinks = [];
+ continue;
+ }
+
+ if (currentLocale is not null) { currentForLinks?.Add(link); continue; }
+
+ if (link.MethodName == "Exactly")
+ {
+ var valStr = FindChainArgString(link, "value", 0);
+ var msg = FindChainArgString(link, "message", 1);
+ if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null)
+ exactMatches[exact] = msg;
+ continue;
+ }
+
+ if (PluralCategoryNames.Contains(link.MethodName))
+ {
+ var msg = FindChainArgString(link, "message", 0);
+ if (msg is not null) categories[link.MethodName] = msg;
+ }
+ }
+
+ if (currentLocale is not null && currentForLinks is not null)
+ forSections.Add((currentLocale, currentForLinks));
+ }
+
+ var sourceText = categories.ContainsKey("Other")
+ ? BuildPluralText(categories, exactMatches, isOrdinal)
+ : null;
+
+ if (sourceText is null) return (null, null);
+
+ var inlines = BuildPluralInlineTranslations(forSections, isOrdinal);
+ return (new TranslationDefinition(key, sourceText, site, inlines), null);
+ }
+
+ // βββ Select ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static (TranslationDefinition?, TranslationReference?) InterpretSelect(
+ string key,
+ IReadOnlyList? chain,
+ DefinitionSite site)
+ {
+ var cases = new Dictionary();
+ string? otherwise = null;
+ var forSections = new List<(string locale, List links)>();
+ string? currentLocale = null;
+ List? currentForLinks = null;
+
+ if (chain is not null)
+ {
+ foreach (var link in chain)
+ {
+ if (link.MethodName == "For")
+ {
+ if (currentLocale is not null && currentForLinks is not null)
+ forSections.Add((currentLocale, currentForLinks));
+ currentLocale = FindChainArgString(link, "locale", 0);
+ currentForLinks = [];
+ continue;
+ }
+
+ if (currentLocale is not null) { currentForLinks?.Add(link); continue; }
+
+ if (link.MethodName == "When")
+ {
+ var selectValue = FindChainArgString(link, "select", 0);
+ var msg = FindChainArgString(link, "message", 1);
+ if (selectValue is not null && msg is not null)
+ cases[StripEnumPrefix(selectValue)] = msg;
+ }
+ else if (link.MethodName == "Otherwise")
+ {
+ otherwise = FindChainArgString(link, "message", 0);
+ }
+ }
+
+ if (currentLocale is not null && currentForLinks is not null)
+ forSections.Add((currentLocale, currentForLinks));
+ }
+
+ if (cases.Count == 0 && otherwise is null) return (null, null);
+
+ var sourceText = new SelectText(cases, otherwise);
+ var inlines = BuildSelectInlineTranslations(forSections);
+ return (new TranslationDefinition(key, sourceText, site, inlines), null);
+ }
+
+ // βββ SelectPlural ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static (TranslationDefinition?, TranslationReference?) InterpretSelectPlural(
+ string key,
+ bool isOrdinal,
+ IReadOnlyList? chain,
+ DefinitionSite site)
+ {
+ var cases = new Dictionary();
+ PluralText? otherwisePlural = null;
+ var forSections = new List<(string locale, List links)>();
+ string? currentLocale = null;
+ List? currentForLinks = null;
+
+ // State for accumulating plural categories within a select case
+ string? currentSelectCase = null;
+ var currentCategories = new Dictionary();
+ var currentExact = new Dictionary();
+ var selectCaseStarted = false;
+
+ void FlushSelectCase()
+ {
+ if (!selectCaseStarted || (currentCategories.Count == 0 && currentExact.Count == 0)) return;
+ var plural = BuildPluralText(currentCategories, currentExact, isOrdinal);
+ if (currentSelectCase is not null)
+ cases[currentSelectCase] = plural;
+ else
+ otherwisePlural = plural;
+ }
+
+ if (chain is not null)
+ {
+ foreach (var link in chain)
+ {
+ // Entering a .For() locale section
+ if (link.MethodName == "For")
+ {
+ FlushSelectCase();
+ selectCaseStarted = false;
+ currentSelectCase = null;
+ currentCategories = [];
+ currentExact = [];
+
+ if (currentLocale is not null && currentForLinks is not null)
+ forSections.Add((currentLocale, currentForLinks));
+ currentLocale = FindChainArgString(link, "locale", 0);
+ currentForLinks = [];
+ continue;
+ }
+
+ // Inside a .For() section β collect links for later interpretation
+ if (currentLocale is not null) { currentForLinks?.Add(link); continue; }
+
+ // .When(select) β new select case
+ if (link.MethodName == "When")
+ {
+ FlushSelectCase();
+ currentSelectCase = StripEnumPrefix(FindChainArgString(link, "select", 0) ?? "");
+ currentCategories = [];
+ currentExact = [];
+ selectCaseStarted = true;
+ continue;
+ }
+
+ // .Otherwise() β fallback select case (no arg)
+ if (link.MethodName == "Otherwise" && link.Arguments.Count == 0)
+ {
+ FlushSelectCase();
+ currentSelectCase = null;
+ currentCategories = [];
+ currentExact = [];
+ selectCaseStarted = true;
+ continue;
+ }
+
+ // .Exactly(value, message) β exact match within current case
+ if (link.MethodName == "Exactly")
+ {
+ selectCaseStarted = true;
+ var valStr = FindChainArgString(link, "value", 0);
+ var msg = FindChainArgString(link, "message", 1);
+ if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null)
+ currentExact[exact] = msg;
+ continue;
+ }
+
+ // Plural category (.One, .Other, etc.)
+ if (PluralCategoryNames.Contains(link.MethodName))
+ {
+ selectCaseStarted = true;
+ var msg = FindChainArgString(link, "message", 0);
+ if (msg is not null) currentCategories[link.MethodName] = msg;
+ }
+ }
+
+ FlushSelectCase();
+ if (currentLocale is not null && currentForLinks is not null)
+ forSections.Add((currentLocale, currentForLinks));
+ }
+
+ if (cases.Count == 0 && otherwisePlural is null) return (null, null);
+
+ var sourceText = new SelectPluralText(cases, otherwisePlural);
+ var inlines = BuildSelectPluralInlineTranslations(forSections, isOrdinal);
+ return (new TranslationDefinition(key, sourceText, site, inlines), null);
+ }
+
+ private static IReadOnlyDictionary? BuildSelectPluralInlineTranslations(
+ List<(string locale, List links)> forSections,
+ bool isOrdinal)
+ {
+ if (forSections.Count == 0) return null;
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (locale, links) in forSections)
+ {
+ var cases = new Dictionary();
+ PluralText? otherwisePlural = null;
+
+ string? currentSelectCase = null;
+ var currentCategories = new Dictionary();
+ var currentExact = new Dictionary();
+ var selectCaseStarted = false;
+
+ void FlushCase()
+ {
+ if (!selectCaseStarted || (currentCategories.Count == 0 && currentExact.Count == 0)) return;
+ var plural = BuildPluralText(currentCategories, currentExact, isOrdinal);
+ if (currentSelectCase is not null)
+ cases[currentSelectCase] = plural;
+ else
+ otherwisePlural = plural;
+ }
+
+ foreach (var link in links)
+ {
+ if (link.MethodName == "When")
+ {
+ FlushCase();
+ currentSelectCase = StripEnumPrefix(FindChainArgString(link, "select", 0) ?? "");
+ currentCategories = [];
+ currentExact = [];
+ selectCaseStarted = true;
+ }
+ else if (link.MethodName == "Otherwise" && link.Arguments.Count == 0)
+ {
+ FlushCase();
+ currentSelectCase = null;
+ currentCategories = [];
+ currentExact = [];
+ selectCaseStarted = true;
+ }
+ else if (link.MethodName == "Exactly")
+ {
+ selectCaseStarted = true;
+ var valStr = FindChainArgString(link, "value", 0);
+ var msg = FindChainArgString(link, "message", 1);
+ if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null)
+ currentExact[exact] = msg;
+ }
+ else if (PluralCategoryNames.Contains(link.MethodName))
+ {
+ selectCaseStarted = true;
+ var msg = FindChainArgString(link, "message", 0);
+ if (msg is not null) currentCategories[link.MethodName] = msg;
+ }
+ }
+
+ FlushCase();
+ if (cases.Count > 0 || otherwisePlural is not null)
+ result[locale] = new SelectPluralText(cases, otherwisePlural);
+ }
+
+ return result.Count > 0 ? result : null;
+ }
+
+ // βββ Definition Factories ββββββββββββββββββββββββββββββββββββββββ
+
+ private static (TranslationDefinition?, TranslationReference?) InterpretDefinitionFactory(
+ ScannedCallSite call,
+ string key,
+ IReadOnlyList? chain,
+ DefinitionSite site)
+ {
+ if (call.MethodName == ExtensionsContract.DefineSimple)
+ {
+ var messageArg = FindArg(call.Arguments, "message");
+ if (messageArg is null) return (null, null);
+ messageArg.Value.TryGetString(out var msg);
+ if (msg is null) return (null, null);
+
+ var inlines = CollectSimpleInlineTranslations(chain);
+ return (new TranslationDefinition(key, new SingularText(msg), site, inlines), null);
+ }
+
+ // DefinePlural, DefineSelect, DefineSelectPlural β key only, text comes from chain
+ if (call.MethodName == ExtensionsContract.DefinePlural)
+ return InterpretPlural(key, false, chain, site);
+
+ if (call.MethodName == ExtensionsContract.DefineSelect)
+ return InterpretSelect(key, chain, site);
+
+ if (call.MethodName == ExtensionsContract.DefineSelectPlural)
+ return InterpretSelectPlural(key, false, chain, site);
+
+ return (null, null);
+ }
+
+ // βββ Shared helpers ββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static PluralText BuildPluralText(
+ Dictionary categories,
+ Dictionary exactMatches,
+ bool isOrdinal)
+ {
+ return new PluralText(
+ Other: categories.GetValueOrDefault("Other", ""),
+ Zero: categories.GetValueOrDefault("Zero"),
+ One: categories.GetValueOrDefault("One"),
+ Two: categories.GetValueOrDefault("Two"),
+ Few: categories.GetValueOrDefault("Few"),
+ Many: categories.GetValueOrDefault("Many"),
+ ExactMatches: exactMatches.Count > 0 ? exactMatches : null,
+ IsOrdinal: isOrdinal);
+ }
+
+ private static IReadOnlyDictionary? BuildPluralInlineTranslations(
+ List<(string locale, List links)> forSections,
+ bool isOrdinal)
+ {
+ if (forSections.Count == 0) return null;
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (locale, links) in forSections)
+ {
+ var categories = new Dictionary();
+ var exactMatches = new Dictionary();
+
+ foreach (var link in links)
+ {
+ if (link.MethodName == "Exactly")
+ {
+ var valStr = FindChainArgString(link, "value", 0);
+ var msg = FindChainArgString(link, "message", 1);
+ if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null)
+ exactMatches[exact] = msg;
+ }
+ else if (PluralCategoryNames.Contains(link.MethodName))
+ {
+ var msg = FindChainArgString(link, "message", 0);
+ if (msg is not null) categories[link.MethodName] = msg;
+ }
+ }
+
+ if (categories.Count > 0 || exactMatches.Count > 0)
+ result[locale] = BuildPluralText(categories, exactMatches, isOrdinal);
+ }
+
+ return result.Count > 0 ? result : null;
+ }
+
+ private static IReadOnlyDictionary? BuildSelectInlineTranslations(
+ List<(string locale, List links)> forSections)
+ {
+ if (forSections.Count == 0) return null;
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (locale, links) in forSections)
+ {
+ var cases = new Dictionary();
+ string? otherwise = null;
+
+ foreach (var link in links)
+ {
+ if (link.MethodName == "When")
+ {
+ var selectValue = FindChainArgString(link, "select", 0);
+ var msg = FindChainArgString(link, "message", 1);
+ if (selectValue is not null && msg is not null)
+ cases[StripEnumPrefix(selectValue)] = msg;
+ }
+ else if (link.MethodName == "Otherwise")
+ {
+ otherwise = FindChainArgString(link, "message", 0);
+ }
+ }
+
+ if (cases.Count > 0 || otherwise is not null)
+ result[locale] = new SelectText(cases, otherwise);
+ }
+
+ return result.Count > 0 ? result : null;
+ }
+
+ // βββ Argument lookup helpers βββββββββββββββββββββββββββββββββββββ
+
+ private static ScannedArgument? FindArg(IReadOnlyList args, string paramName)
+ => args.FirstOrDefault(a => a.ParameterName == paramName);
+
+ private static ScannedArgument? FindArgByPosition(IReadOnlyList args, int position)
+ => args.FirstOrDefault(a => a.Position == position);
+
+ ///
+ /// Extract a string value from a chain link argument by parameter name or position.
+ ///
+ private static string? FindChainArgString(FluentChainWalker.ChainLink link, string paramName, int fallbackPosition)
+ {
+ foreach (var arg in link.Arguments)
+ {
+ var value = arg.Value.Accept(ValueExtractor.Instance, null);
+ if (value is null) continue;
+
+ var argParam = arg.Parameter?.Name;
+ if (argParam == paramName || (argParam is null && arg == link.Arguments[fallbackPosition]))
+ {
+ if (value.TryGetString(out var s)) return s;
+ if (value is OperationValue.Constant { Value: var v }) return v?.ToString();
+ if (value is OperationValue.SymbolReference { Symbol: var sym }) return sym.Name;
+ }
+ }
+
+ // Fallback: try by position
+ if (fallbackPosition < link.Arguments.Count)
+ {
+ var value = link.Arguments[fallbackPosition].Value.Accept(ValueExtractor.Instance, null);
+ if (value is not null && value.TryGetString(out var s)) return s;
+ if (value is OperationValue.Constant { Value: var v }) return v?.ToString();
+ if (value is OperationValue.SymbolReference { Symbol: var sym }) return sym.Name;
+ }
+
+ return null;
+ }
+
+ private static string StripEnumPrefix(string value)
+ {
+ var lastDot = value.LastIndexOf('.');
+ return lastDot >= 0 ? value[(lastDot + 1)..] : value;
+ }
+
+ ///
+ /// Produces the PO #. context comment: .Translation, TranslationDefinitions.DefineSimple, etc.
+ ///
+ private static string FormatContext(ScannedCallSite call)
+ {
+ if (call.MethodName.StartsWith(ExtensionsContract.DefinePrefix, StringComparison.Ordinal))
+ return $"{nameof(BlazorLocalization.Extensions.Translation.Definitions.TranslationDefinitions)}.{call.MethodName}";
+
+ return $".{call.MethodName}";
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs
new file mode 100644
index 0000000..fd27a26
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs
@@ -0,0 +1,98 @@
+using BlazorLocalization.Extractor.Domain;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Converts raw walker hits into structured records.
+/// Extracts arguments via and chains via .
+///
+internal static class CallSiteBuilder
+{
+ public static ScannedCallSite Build(
+ IOperation operation,
+ ISymbol symbol,
+ IReadOnlyList arguments,
+ SourceFilePath file,
+ int line)
+ {
+ var scannedArgs = ExtractArguments(arguments);
+
+ return operation switch
+ {
+ IPropertyReferenceOperation propRef when propRef.Property.IsIndexer =>
+ new ScannedCallSite(
+ MethodName: "this[]",
+ ContainingTypeName: propRef.Property.ContainingType?.Name ?? "?",
+ CallKind: CallKind.Indexer,
+ File: file,
+ Line: line,
+ Arguments: scannedArgs,
+ Chain: null),
+
+ IInvocationOperation invocation =>
+ BuildFromInvocation(invocation, scannedArgs, file, line),
+
+ _ => new ScannedCallSite(
+ MethodName: symbol.Name,
+ ContainingTypeName: symbol.ContainingType?.Name ?? "?",
+ CallKind: CallKind.MethodCall,
+ File: file,
+ Line: line,
+ Arguments: scannedArgs,
+ Chain: null)
+ };
+ }
+
+ private static ScannedCallSite BuildFromInvocation(
+ IInvocationOperation invocation,
+ IReadOnlyList arguments,
+ SourceFilePath file,
+ int line)
+ {
+ var method = invocation.TargetMethod;
+
+ // Detect builder-returning calls (Translation() β SimpleBuilder, PluralBuilder, etc.)
+ var returnType = method.ReturnType;
+ var returnsBuilder = FluentChainWalker.IsBuilderType(returnType as INamedTypeSymbol);
+
+ // A call is a "definition" if it returns a builder type OR is a DefineXxx factory
+ var isDefinitionLike = returnsBuilder ||
+ method.Name.StartsWith("Define", StringComparison.Ordinal);
+
+ var callKind = isDefinitionLike ? CallKind.ExtensionMethod : CallKind.MethodCall;
+
+ // Only collect fluent chain for builder-returning calls
+ var chain = returnsBuilder ? FluentChainWalker.WalkChain(invocation) : null;
+ if (chain is { Count: 0 }) chain = null;
+
+ return new ScannedCallSite(
+ MethodName: method.Name,
+ ContainingTypeName: method.ContainingType?.Name ?? "?",
+ CallKind: callKind,
+ File: file,
+ Line: line,
+ Arguments: arguments,
+ Chain: chain);
+ }
+
+ private static IReadOnlyList ExtractArguments(IReadOnlyList arguments)
+ {
+ var result = new List(arguments.Count);
+
+ for (var i = 0; i < arguments.Count; i++)
+ {
+ var arg = arguments[i];
+ var value = arg.Value.Accept(ValueExtractor.Instance, null)
+ ?? new OperationValue.Unrecognized(arg.Value.Kind, arg.Value.Syntax.ToString());
+
+ result.Add(new ScannedArgument(
+ Position: i,
+ ParameterName: arg.Parameter?.Name,
+ Value: value));
+ }
+
+ return result;
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs
new file mode 100644
index 0000000..29c0add
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs
@@ -0,0 +1,82 @@
+using BlazorLocalization.Extractor.Domain;
+using Microsoft.CodeAnalysis;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Extracts records from enum members decorated with
+/// [Translation] attributes. Uses the Symbol API (not IOperation) because attributes
+/// are declarative metadata, not executable operations.
+///
+internal static class EnumAttributeInterpreter
+{
+ ///
+ /// Inspects a field symbol (enum member) for [Translation] attributes.
+ /// Returns a only β enum declarations are definitions,
+ /// not usages. The reference slot is always null.
+ ///
+ public static (TranslationDefinition? Definition, TranslationReference? Reference) TryInterpret(
+ IFieldSymbol field,
+ INamedTypeSymbol translationAttribute,
+ SourceFilePath file,
+ int line)
+ {
+ var matchingAttrs = field.GetAttributes()
+ .Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, translationAttribute))
+ .ToList();
+
+ if (matchingAttrs.Count == 0)
+ return (null, null);
+
+ string? customKey = null;
+ string? sourceText = null;
+ Dictionary? inlineTranslations = null;
+
+ foreach (var attr in matchingAttrs)
+ {
+ // Constructor arg [0] = message
+ var message = attr.ConstructorArguments is [{ Value: string msg }] ? msg : null;
+ if (message is null)
+ continue;
+
+ // Named args: Locale, Key
+ string? locale = null;
+ string? key = null;
+ foreach (var named in attr.NamedArguments)
+ {
+ if (named.Key == ExtensionsContract.AttrLocale && named.Value.Value is string loc)
+ locale = loc;
+ else if (named.Key == ExtensionsContract.AttrKey && named.Value.Value is string k)
+ key = k;
+ }
+
+ if (locale is not null)
+ {
+ inlineTranslations ??= new(StringComparer.OrdinalIgnoreCase);
+ inlineTranslations[locale] = new SingularText(message);
+ }
+ else
+ {
+ sourceText = message;
+ customKey ??= key;
+ }
+ }
+
+ if (sourceText is null)
+ return (null, null);
+
+ var enumTypeName = field.ContainingType.Name;
+ var memberName = field.Name;
+ var entryKey = customKey ?? $"Enum.{enumTypeName}_{memberName}";
+
+ var site = new DefinitionSite(file, line, DefinitionKind.EnumAttribute);
+
+ var def = new TranslationDefinition(
+ entryKey,
+ new SingularText(sourceText),
+ site,
+ inlineTranslations);
+
+ return (def, null);
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/ExtensionsContract.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ExtensionsContract.cs
new file mode 100644
index 0000000..6c46d07
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ExtensionsContract.cs
@@ -0,0 +1,163 @@
+using BlazorLocalization.Extensions;
+using BlazorLocalization.Extensions.Translation;
+using BlazorLocalization.Extensions.Translation.Definitions;
+using Microsoft.Extensions.Localization;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Single source of truth for every reference from the Extractor to the Extensions project.
+/// Compiler-verified where C# allows it (typeof, nameof); documented string
+/// constants where it doesn't (C#14 extension members, parameter names).
+///
+/// If anything in the Extensions project is renamed, this file either breaks the build
+/// (for typeof/nameof references) or is the one place to update
+/// (for string constants). The test ExtensionsContractTests validates the
+/// parameter-name constants via reflection.
+///
+///
+internal static class ExtensionsContract
+{
+ // ββ Metadata names (for Compilation.GetTypeByMetadataName) ββββββββ
+
+ /// Fully-qualified name of .
+ public static readonly string MetaIStringLocalizer =
+ typeof(IStringLocalizer).FullName!;
+
+ /// Fully-qualified name of .
+ public static readonly string MetaTranslationAttribute =
+ typeof(TranslationAttribute).FullName!;
+
+ /// Fully-qualified name of .
+ public static readonly string MetaTranslationDefinitions =
+ typeof(TranslationDefinitions).FullName!;
+
+ // ββ Assembly reference ββββββββββββββββββββββββββββββββββββββββββββ
+
+ /// Location of the Extensions assembly, needed as a compilation reference.
+ public static readonly string ExtensionsAssemblyLocation =
+ typeof(BlazorLocalization.Extensions.StringLocalizerExtensions).Assembly.Location;
+
+ // ββ Extension method names ββββββββββββββββββββββββββββββββββββββββ
+ // C#14 `extension(IStringLocalizer)` block members cannot be referenced
+ // via nameof β the methods live inside the extension block, not on the
+ // containing static class. These string constants are the minimum fallback.
+
+ /// The .Translation() extension method name.
+ /// β C#14 extension block member.
+ public const string Translation = "Translation";
+
+ /// The .Display() extension method name.
+ /// β C#14 extension block member.
+ public const string Display = "Display";
+
+ // ββ Definition factory method names βββββββββββββββββββββββββββββββ
+
+ ///
+ public const string DefineSimple = nameof(TranslationDefinitions.DefineSimple);
+
+ ///
+ public const string DefinePlural = nameof(TranslationDefinitions.DefinePlural);
+
+ ///
+ public const string DefineSelect = nameof(TranslationDefinitions.DefineSelect);
+
+ ///
+ public const string DefineSelectPlural = nameof(TranslationDefinitions.DefineSelectPlural);
+
+ /// Shared prefix for all Define* factory methods.
+ public const string DefinePrefix = "Define";
+
+ // ββ Builder / Definition type names (for FluentChainWalker) ββββββ
+
+ public const string TypeSimpleBuilder = nameof(SimpleBuilder);
+ public const string TypePluralBuilder = nameof(PluralBuilder);
+ // Generic types: nameof(SelectBuilder) yields "SelectBuilder"
+ public const string TypeSelectBuilder = nameof(SelectBuilder);
+ public const string TypeSelectPluralBuilder = nameof(SelectPluralBuilder);
+ public const string TypeSimpleDefinition = nameof(SimpleDefinition);
+ public const string TypePluralDefinition = nameof(PluralDefinition);
+ public const string TypeSelectDefinition = nameof(SelectDefinition);
+ public const string TypeSelectPluralDefinition = nameof(SelectPluralDefinition);
+
+ // ββ Chain method names ββββββββββββββββββββββββββββββββββββββββββββ
+
+ /// , , etc.
+ public const string ChainFor = nameof(SimpleBuilder.For);
+
+ ///
+ public const string ChainOne = nameof(PluralBuilder.One);
+
+ ///
+ public const string ChainOther = nameof(PluralBuilder.Other);
+
+ ///
+ public const string ChainZero = nameof(PluralBuilder.Zero);
+
+ ///
+ public const string ChainTwo = nameof(PluralBuilder.Two);
+
+ ///
+ public const string ChainFew = nameof(PluralBuilder.Few);
+
+ ///
+ public const string ChainMany = nameof(PluralBuilder.Many);
+
+ ///
+ public const string ChainExactly = nameof(PluralBuilder.Exactly);
+
+ ///
+ public const string ChainWhen = nameof(SelectBuilder.When);
+
+ ///
+ public const string ChainOtherwise = nameof(SelectBuilder.Otherwise);
+
+ // ββ [Translation] attribute property names ββββββββββββββββββββββββ
+
+ ///
+ public const string AttrLocale = nameof(TranslationAttribute.Locale);
+
+ ///
+ public const string AttrKey = nameof(TranslationAttribute.Key);
+
+ // ββ Parameter names ββββββββββββββββββββββββββββββββββββββββββββββ
+ // C# does not support nameof for method parameters.
+ // These constants match the parameter names in StringLocalizerExtensions
+ // and the builder chain methods. Validated by ExtensionsContractTests.
+
+ /// The key parameter on
+ /// and all .Translation() overloads.
+ public const string ParamKey = "key";
+
+ /// The name parameter on IStringLocalizer.this[string name].
+ public const string ParamName = "name";
+
+ /// The message parameter on
+ /// and builder chain methods like .
+ public const string ParamMessage = "message";
+
+ /// The howMany parameter on plural .Translation() overloads.
+ public const string ParamHowMany = "howMany";
+
+ /// The select parameter on select .Translation() overloads
+ /// and .
+ public const string ParamSelect = "select";
+
+ /// The ordinal parameter on plural .Translation() overloads.
+ public const string ParamOrdinal = "ordinal";
+
+ /// The locale parameter on .
+ public const string ParamLocale = "locale";
+
+ /// The value parameter on .
+ public const string ParamValue = "value";
+
+ // ββ Plural category names ββββββββββββββββββββββββββββββββββββββββ
+ // These serve double duty: they match both the builder method names
+ // (PluralBuilder.Zero, .One, etc.) and the CLDR category identifiers.
+
+ public static readonly HashSet PluralCategoryNames =
+ [
+ ChainZero, ChainOne, ChainTwo, ChainFew, ChainMany, ChainOther
+ ];
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/FluentChainWalker.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/FluentChainWalker.cs
new file mode 100644
index 0000000..4aa6f57
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/FluentChainWalker.cs
@@ -0,0 +1,63 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Walks Parent from a detected .Translation() call upward through the
+/// fluent builder chain (.For(), .One(), .Other(), .When(), .Otherwise(), etc.).
+/// Returns chain links in source order (innermost first β outermost last).
+///
+/// IOperation trees are inverted relative to source reading order.
+/// In source we write: .Translation("key", "msg").For("da", "dansk").For("de", "deutsch")
+/// But in the IOperation tree, evaluation order rules β what executes first is deepest:
+///
+/// .For("de", ...) β outermost (Parent of .For("da"))
+/// Instance: .For("da", ...)
+/// Instance: .Translation(...) β deepest child, executes first
+///
+/// So to collect the chain from .Translation(), we walk UP via Parent.
+///
+internal static class FluentChainWalker
+{
+ private static readonly HashSet BuilderTypeNames =
+ [
+ ExtensionsContract.TypeSimpleBuilder,
+ ExtensionsContract.TypePluralBuilder,
+ ExtensionsContract.TypeSelectBuilder,
+ ExtensionsContract.TypeSelectPluralBuilder,
+ ExtensionsContract.TypeSimpleDefinition,
+ ExtensionsContract.TypePluralDefinition,
+ ExtensionsContract.TypeSelectDefinition,
+ ExtensionsContract.TypeSelectPluralDefinition,
+ ];
+
+ public record ChainLink(string MethodName, IReadOnlyList Arguments);
+
+ ///
+ /// Starting from a detected IOperation (e.g. .Translation()), walk Parent
+ /// collecting each IInvocationOperation whose containing type is a known builder.
+ ///
+ public static List WalkChain(IOperation anchor)
+ {
+ var links = new List();
+ var current = anchor.Parent;
+
+ while (current is IInvocationOperation invocation &&
+ IsBuilderType(invocation.TargetMethod.ContainingType))
+ {
+ links.Add(new ChainLink(
+ invocation.TargetMethod.Name,
+ invocation.Arguments));
+ current = invocation.Parent;
+ }
+
+ return links;
+ }
+
+ internal static bool IsBuilderType(INamedTypeSymbol? type)
+ {
+ if (type is null) return false;
+ return BuilderTypeNames.Contains(type.Name);
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/LineMap.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LineMap.cs
new file mode 100644
index 0000000..688e24f
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LineMap.cs
@@ -0,0 +1,33 @@
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Maps generated C# line numbers back to original Razor line numbers
+/// using #line directive positions collected during Razor compilation.
+///
+internal sealed class LineMap
+{
+ private readonly List<(int GeneratedLine, int OriginalLine)> _entries;
+
+ public LineMap(List<(int GeneratedLine, int OriginalLine)> entries)
+ {
+ _entries = entries;
+ }
+
+ ///
+ /// Given a 1-based line number in generated C#, returns the corresponding
+ /// 1-based line in the original .razor / .cshtml file.
+ /// Returns 0 if the line precedes any #line directive.
+ ///
+ public int MapToOriginalLine(int generatedLine)
+ {
+ var mapped = 0;
+ foreach (var entry in _entries)
+ {
+ if (entry.GeneratedLine > generatedLine)
+ break;
+ // #line N means the line AFTER the directive is original line N
+ mapped = entry.OriginalLine + (generatedLine - entry.GeneratedLine - 1);
+ }
+ return mapped;
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/LocalizerOperationWalker.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LocalizerOperationWalker.cs
new file mode 100644
index 0000000..07387ac
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LocalizerOperationWalker.cs
@@ -0,0 +1,60 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// OperationWalker that detects IStringLocalizer usage β both indexer access
+/// (localizer["key"]) and method invocations (localizer.GetString(),
+/// localizer.Translation()) β as well as DefineXxx() static factory calls
+/// on TranslationDefinitions.
+///
+internal sealed class LocalizerOperationWalker(
+ INamedTypeSymbol targetInterface,
+ INamedTypeSymbol? definitionFactory = null) : OperationWalker
+{
+ private readonly List<(IOperation Op, ISymbol Symbol, IReadOnlyList Arguments)> _results = [];
+
+ public IReadOnlyList<(IOperation Op, ISymbol Symbol, IReadOnlyList Arguments)> Results => _results;
+
+ public override void VisitPropertyReference(IPropertyReferenceOperation operation)
+ {
+ if (operation.Property.IsIndexer &&
+ IsAssignableTo(operation.Instance?.Type, targetInterface))
+ {
+ _results.Add((operation, operation.Property, operation.Arguments));
+ }
+
+ base.VisitPropertyReference(operation);
+ }
+
+ public override void VisitInvocation(IInvocationOperation operation)
+ {
+ var receiverType = operation.Instance?.Type
+ ?? operation.TargetMethod.Parameters.FirstOrDefault()?.Type;
+
+ if (IsAssignableTo(receiverType, targetInterface))
+ {
+ _results.Add((operation, operation.TargetMethod, operation.Arguments));
+ }
+ else if (definitionFactory is not null
+ && operation.Instance is null
+ && SymbolEqualityComparer.Default.Equals(
+ operation.TargetMethod.ContainingType, definitionFactory))
+ {
+ _results.Add((operation, operation.TargetMethod, operation.Arguments));
+ }
+
+ base.VisitInvocation(operation);
+ }
+
+ internal static bool IsAssignableTo(ITypeSymbol? type, INamedTypeSymbol targetInterface)
+ {
+ if (type == null) return false;
+ if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, targetInterface))
+ return true;
+ return type.AllInterfaces.Any(i =>
+ SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, targetInterface) ||
+ SymbolEqualityComparer.Default.Equals(i, targetInterface));
+ }
+}
diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/OperationValue.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/OperationValue.cs
new file mode 100644
index 0000000..15fd4ce
--- /dev/null
+++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/OperationValue.cs
@@ -0,0 +1,160 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace BlazorLocalization.Extractor.Adapters.Roslyn;
+
+///
+/// Structured result from visiting an IOperation argument value.
+/// Adapter-internal β used by for domain type production
+/// and by the inspect command for diagnostic detail.
+///
+internal abstract record OperationValue
+{
+ /// Compile-time constant (literal, const field, nameof result, folded expression).
+ public sealed record Constant(object? Value, ITypeSymbol? Type) : OperationValue;
+
+ /// Reference to a non-constant symbol (field, local, parameter).
+ public sealed record SymbolReference(ISymbol Symbol, ITypeSymbol? Type) : OperationValue;
+
+ /// Array initializer with per-element values.
+ public sealed record ArrayElements(ImmutableArray Items) : OperationValue;
+
+ /// A nameof() expression that resolved to a string constant.
+ public sealed record NameOf(string Name) : OperationValue;
+
+ /// Anything else β the adapter couldn't classify it.
+ public sealed record Unrecognized(OperationKind Kind, string Syntax) : OperationValue;
+
+ ///
+ /// Tries to extract a string value. Returns true for Constant(string) and NameOf.
+ /// This is the adapterβdomain bridge: if this returns true, the argument is literal.
+ ///
+ public bool TryGetString(out string? value)
+ {
+ switch (this)
+ {
+ case Constant { Value: string s }:
+ value = s;
+ return true;
+ case NameOf { Name: var n }:
+ value = n;
+ return true;
+ default:
+ value = null;
+ return false;
+ }
+ }
+
+ ///
+ /// Tries to extract an int value. Returns true for Constant(int).
+ ///
+ public bool TryGetInt(out int value)
+ {
+ if (this is Constant { Value: int i })
+ {
+ value = i;
+ return true;
+ }
+ value = 0;
+ return false;
+ }
+
+ /// Whether this value is a compile-time constant.
+ public bool IsLiteral => this is Constant or NameOf;
+
+ ///
+ /// Human-readable display text for diagnostic output.
+ /// Same pattern as .
+ ///
+ public string Display(int maxLength = 80) => this switch
+ {
+ Constant { Value: string s } => $"\"{Truncate(s, maxLength - 2)}\"",
+ Constant { Value: var v } => v?.ToString() ?? "null",
+ SymbolReference { Symbol: var s } => s.Name,
+ NameOf { Name: var n } => $"nameof({n})",
+ ArrayElements { Items: var items } => $"[{items.Length} elements]",
+ Unrecognized { Syntax: var s } => Truncate(s, maxLength),
+ _ => "?"
+ };
+
+ private static string Truncate(string text, int maxLength) =>
+ text.Length > maxLength ? text[..(maxLength - 3)] + "..." : text;
+}
+
+///
+/// Extracts from an using Roslyn's
+/// visitor dispatch. Called via operation.Accept(ValueExtractor.Instance, null).
+///
+internal sealed class ValueExtractor : OperationVisitor