diff --git a/.agents/references/reviews/aot-package-compat.md b/.agents/references/reviews/aot-package-compat.md index efe2875..7e21792 100644 --- a/.agents/references/reviews/aot-package-compat.md +++ b/.agents/references/reviews/aot-package-compat.md @@ -64,6 +64,28 @@ than published, so trimming cannot reach them — but packaging can drop them. generator carries `PrivateAssets="all"` and `ReferenceOutputAssembly="false"` for that reason; losing either leaks `Microsoft.CodeAnalysis.CSharp` into the package's dependency list. +## The build wiring on every supported SDK + +`build/ResXLocalization.Resx.targets` runs inside every consumer's build, on whichever SDK that consumer +has - the .NET 8 SDK as much as the one this repository pins - and inside WPF's temporary markup-compile +pass, a copy of the project named `__wpftmp` that WPF builds before the real one. Neither +is visible to a test suite here, which builds on the pinned SDK with the repository's own +`Directory.Build.props`. + +- **Anything the wiring or the generator derives from a project property must hold in the temporary + pass, on every supported SDK.** `MSBuildProjectName`, and every `RootNamespace` computed from it, + differ there; the whole mechanism, the fix and its limits are explained in the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. The fix + changes only `ManifestResourceName`, `LogicalName` and `CustomToolNamespace` of the targets file's own + copies of the `.resx` items. A change that removes it, widens it to `RootNamespace` or to other items + or metadata, or adds a new dependency on a property derived from the project name, is a finding. +- **A claim about what an SDK does is checked, not assumed.** Cite the targets file or the task of each + supported SDK that the claim is about, with file and line - for example + `sdk//Sdks/Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.WinFX.targets`. A claim you + could not check is reported as unchecked, never as a fact. +- **What catches it:** the "Package consumers (.NET 8 SDK)" step of `scripts/pre-release-gate.ps1`, + and CI's "Consume packages on .NET 8 SDK" job, through `WpfConsumer` and `WpfDerivedNamespaceConsumer` + (the second also on the pinned SDK). + ## What the tooling cannot tell you - **`TrimmerSingleWarn`.** Left at its default, ILC collapses every diagnostic from an assembly into @@ -86,6 +108,7 @@ name the command: ```text pwsh -File scripts/verify-package-aot.ps1 -Pack # both frameworks, from a fresh pack pwsh -File scripts/verify-package-aot.ps1 -Framework net8.0 # the LTS floor alone +pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation # every consumer, pinned SDK and .NET 8 SDK ``` Do not run them yourself, and do not fix what you found. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d02f00..9165a08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -419,6 +419,13 @@ jobs: working-directory: ${{ env.CONSUMER_DIRECTORY }} run: dotnet run --project WpfConsumer/WpfConsumer.csproj --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + # RootNamespace derived from the project name, which WPF's temporary markup-compile pass gets wrong; + # see the comment "WPF's temporary markup-compile pass" in build/ResXLocalization.Resx.targets. + - name: Run WPF package consumer with a derived RootNamespace (net8.0 SDK) + if: runner.os == 'Windows' + working-directory: ${{ env.CONSUMER_DIRECTORY }} + run: dotnet run --project WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }} + - name: Run combined Avalonia and WPF package consumer (net8.0 SDK) if: runner.os == 'Windows' working-directory: ${{ env.CONSUMER_DIRECTORY }} @@ -436,7 +443,7 @@ jobs: # everything trimming can break (a satellite that is no longer loaded, a resource name that no longer # resolves) produces a binary that starts and answers wrongly. # - # Three legs. The trimming behaviour under test belongs to the ILC compiler and does not vary by host, + # Three runs. The trimming behaviour under test belongs to the ILC compiler and does not vary by host, # so net8.0 - the LTS floor the packages promise - is exercised on Linux, and net10.0 on both. verify-package-aot: name: Verify packed-package Native AOT (${{ matrix.framework }}, ${{ matrix.runtime }}) @@ -466,7 +473,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - # The 8.0 runtime is what the net8.0 leg's build-time tooling runs on. + # The 8.0 runtime is what the build-time tooling of the net8.0 run runs on. dotnet-version: | 8.0.x 10.0.x diff --git a/AGENTS.md b/AGENTS.md index aaece46..6d01ef9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,10 @@ change rendering rather than layout, are in | --- | --- | --- | | One UI package changed | mirror it into the other, and mirror the tests | `ui_parity_reviewer` | | Resource lookup, culture fallback, satellite discovery, the generator's output, or the packaging that carries them | `pwsh -File scripts/verify-package-aot.ps1 -Pack` | `aot_package_compat_reviewer` | -| The package contents, the `buildTransitive` wiring, or a dependency version | pack, then run each project under `tests/package-consumption` | — | +| The package contents, the `buildTransitive` wiring, the generator's output, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`: packs, then runs every project under `tests/package-consumption` on the pinned SDK and again on the .NET 8 SDK | `aot_package_compat_reviewer` | + +The .NET 8 SDK step needs an 8.0 SDK installed next to the pinned one. It exists because the SDKs differ in +more than their compiler - for example in WPF's temporary markup-compile pass, see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. Nothing is trimmed on the just-in-time compiler, so **no test suite in this repository can see trimming damage**. The AOT gate publishes a package-only consumer natively and runs it; that is the only check that diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fc22f..ffda733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ this project adheres to [Semantic Versioning](https://semver.org/). `FromDictionary` or `FromObject` and checked when they are built: positional, named or mixed. - Enum lookups with format arguments: `Get(sortOrder, MessageArguments.FromValues(count))`. - `ILocalizer.EnumKeyBuilder`, one replaceable enum key convention for code and XAML. +- Typed keys for every neutral `.resx`, with or without a `.Designer.cs`: files without a designer, + neutral files with a dot in their name and projects using `GenerateResxSource` produce keys classes. + Without a designer, the keys class takes its namespace from the file's `CustomToolNamespace` when it + is set. +- The generated `Keys.ResourceManager` property: the resource manager of that `.resx`, which every + key of the class uses. Register it for search-all lookups and pass it for scoped lookups. +- Generator warnings `RXLGEN003` (a `.resx` reached the generator without its MSBuild resource name) + and `RXLGEN004` (two neutral `.resx` files would produce the same keys class). ### Changed @@ -60,6 +68,20 @@ this project adheres to [Semantic Versioning](https://semver.org/). - **BREAKING:** A custom `ILocalizer` implementation has exactly three `Get` overloads - string, `ResourceKey` and enum keys, each with an optional `MessageArguments` - and implements `EnumKeyBuilder`. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Typed keys use the generated `Keys.ResourceManager`, no longer the designer's + `.ResourceManager`; `ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager)` + is now `false`. See the [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Files without a designer, neutral files with a dot in their name and projects using + `GenerateResxSource` now produce keys classes, which can collide with types of your own. See the + [migration guide](docs/guides/migration-2.0.md). +- **BREAKING:** Two neutral `.resx` files that produce the same keys class report `RXLGEN004` and + generate neither class, instead of merging into one partial class. See the + [migration guide](docs/guides/migration-2.0.md). + +### Removed + +- The generator warning `RXLGEN002` (a `.resx` without a recognized classic designer): a designer is no + longer required. ## [1.1.0] - 2026-07-20 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8b6bea..ea9a04e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,18 +88,22 @@ The script names them, with their trigger and their command, in its own summary | Run it when the change touches | Command | | --- | --- | | Resource lookup, culture fallback, satellite discovery, the generated keys, or the packaging that carries them | `pwsh -File scripts/verify-package-aot.ps1 -Pack` | -| What the packages contain, the `buildTransitive` wiring, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`, which packs and then runs every consumer, on both of AvaloniaConsumer's target frameworks | +| What the packages contain, the `buildTransitive` wiring, the generated keys, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`, which packs and then runs every consumer on the pinned SDK, on both of AvaloniaConsumer's target frameworks, and again on the .NET 8 SDK, on net8.0 | **`pre-release-gate.ps1`** is everything CI checks that can honestly be checked on your machine, in CI's order, stopping at the first failure: the ignored-revision check, line endings, tidiness, a Release build, every test suite, the DocFX metadata and site build with `--warningsAsErrors`, the pack with package -validation, each Native AOT leg this host can run, and the package consumers against the packages just -packed. It ends in one line: `PASSED: All checks passed.` or `FAILED: Check failed. See output.` +validation, the Native AOT gate for each framework and runtime this host can build, and the package +consumers against the packages just packed - on the pinned SDK, and again on the .NET 8 SDK, the way CI's +minimum-SDK job builds them. The SDKs differ in more than their compiler - for example in WPF's +temporary markup-compile pass, see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. It ends in one line: +`PASSED: All checks passed.` or `FAILED: Check failed. See output.` Its comment-based help lists every CI job it does **not** reproduce, and why. Read that before treating a green run as a promise that CI will be green. -`-SkipNativeAot`, `-SkipConsumers` and `-SkipDocumentation` cover the checks with heavy prerequisites. +`-SkipNativeAot`, `-SkipConsumers`, `-SkipMinimumSdk` and `-SkipDocumentation` cover the checks with heavy +prerequisites. ## Checking versus fixing diff --git a/Directory.Packages.props b/Directory.Packages.props index b03d98f..1678855 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -64,8 +64,9 @@ diff --git a/build/ResXLocalization.Resx.targets b/build/ResXLocalization.Resx.targets index 769a968..233ac57 100644 --- a/build/ResXLocalization.Resx.targets +++ b/build/ResXLocalization.Resx.targets @@ -6,8 +6,17 @@ @(EmbeddedResource) / @(Compile) are populated by the SDK default-item globs, so this covers each project's resources automatically and is benign for projects with none. - Done in a target (not a bare ItemGroup) so the per-item metadata Conditions batch correctly and run - before the compiler collects @(AdditionalFiles). + The generator also needs MSBuild's name for each .resx: ManifestResourceName (or LogicalName, which + replaces it) is the name the resources are embedded under, and WithCulture says whether the file is a + satellite. CustomToolNamespace, which only the IDE's designer tools read otherwise, names the + namespace of a keys class whose .resx has no designer. A generator cannot read MSBuild items. The only + channel is the analyzer config file that GenerateMSBuildEditorConfigFile writes into obj/, as a + snapshot of every CompilerVisibleItemMetadata value of the @(AdditionalFiles) that exist when it runs. + So the target below: + * depends on PrepareResourceNames, the target that computes ManifestResourceName and WithCulture; + * runs before GenerateMSBuildEditorConfigFileShouldRun, so the items are in the snapshot. An item + added later (for example before CoreCompile) reaches the generator without its metadata. + Done in a target (not a bare ItemGroup) so the per-item metadata Conditions batch correctly. SINGLE SOURCE OF TRUTH. This file is consumed two ways: * In-repo: Directory.Build.targets s it (the sample/tests use ProjectReference and so do @@ -16,11 +25,71 @@ file to buildTransitive/.targets (the file name must equal the PackageId so NuGet auto-imports it for direct and transitive consumers). --> + + + + + + + + + + BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun" + DependsOnTargets="PrepareResourceNames"> + + + + <_ResXLocalizationWpfTmpSuffix>$(MSBuildProjectName.Substring($(_TargetAssemblyProjectName.Length))) + + + - + <_ResXLocalizationResx Include="@(EmbeddedResource)" + Condition="'%(Extension)' == '.resx'" /> + <_ResXLocalizationResx Condition="'$(_ResXLocalizationWpfTmpSuffix)' != ''"> + $([System.String]::Copy('%(_ResXLocalizationResx.ManifestResourceName)').Replace('$(_ResXLocalizationWpfTmpSuffix)', '')) + $([System.String]::Copy('%(_ResXLocalizationResx.LogicalName)').Replace('$(_ResXLocalizationWpfTmpSuffix)', '')) + $([System.String]::Copy('%(_ResXLocalizationResx.CustomToolNamespace)').Replace('$(_ResXLocalizationWpfTmpSuffix)', '')) + + + <_ResXLocalizationResx Remove="@(_ResXLocalizationResx)" /> + ``` + +## Typed keys use the generated `ResourceManager` + +Every generated `Keys` class has its own `ResourceManager` property, created from the name the +`.resx` is embedded under, and every key of the class uses it instead of the designer's +`.ResourceManager`. Lookups return the same text. Registering or scoping with the designer's +instance still works, but it is a second instance of the same file with its own cache; use +`Keys.ResourceManager`. + +The generator reads that name from the analyzer config file the SDK writes into `obj/`, so every keys +class - with or without a designer - now depends on it. A project that sets +`GenerateMSBuildEditorConfigFile` to `false` gets `RXLGEN003` and no keys classes; remove that setting. + +Before: + +```csharp +ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager); // true + +Localizer.Current.RegisterResourceManager(AppStrings.ResourceManager); +Localizer.Current.Get("Greeting", resourceManager: AppStrings.ResourceManager); +``` + +After: + +```csharp +ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStringsKeys.ResourceManager); // true +ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager); // false + +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); +Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.ResourceManager); +``` + +## More `.resx` files produce keys classes + +Every neutral `.resx` now produces a keys class: a file without a `.Designer.cs`, a neutral file with a +dot in its name, and the files of a project that uses the SDK's `GenerateResxSource`. `RXLGEN002` is no +longer reported. If a new `Keys` class collides with a type of your own, rename the `.resx`. + +Before: + +```text +Resources/Errors.resx (no designer) -> no ErrorsKeys, warning RXLGEN002 +Resources/My.Strings.resx -> no keys class (taken for a satellite) +``` + +After: + +```text +Resources/Errors.resx (no designer) -> ErrorsKeys +Resources/My.Strings.resx -> My_StringsKeys +``` + +## Two files that produce the same keys class are no longer merged + +Two neutral `.resx` files that produce the same namespace and class name - for example two +`Strings.resx` in different folders whose designers declare the same namespace - were merged into one +partial class. They now report `RXLGEN004`, and neither class is generated. Rename one of the files, or +change its designer's namespace or its `CustomToolNamespace`. + +Before: + +```text +A/Strings.resx + B/Strings.resx, both designers in namespace MyApp -> one partial MyApp.StringsKeys +``` + +After: + +```text +A/Strings.resx + B/Strings.resx, both designers in namespace MyApp -> RXLGEN004 at each file, no StringsKeys +B/Strings.resx renamed to B/MoreStrings.resx -> MyApp.StringsKeys and MyApp.MoreStringsKeys +``` diff --git a/docs/reference/generated-keys.md b/docs/reference/generated-keys.md index 78df7fe..65226ff 100644 --- a/docs/reference/generated-keys.md +++ b/docs/reference/generated-keys.md @@ -5,18 +5,19 @@ that feeds it. Nothing has to be configured for it to run. ## Which files qualify -A `.resx` file produces a typed key class when **all** of these hold: +Every **neutral** `.resx` file produces a typed key class: `AppStrings.resx`, not `AppStrings.de.resx`. +The neutral file defines the key set; a satellite translates it. -| Requirement | Why | -| --- | --- | -| It is the **neutral** file — `AppStrings.resx`, not `AppStrings.de.resx` | The neutral file defines the key set; a satellite translates it | -| Its name has no dot before the extension | A dot is how a culture is named, so `My.Strings.resx` cannot be told apart from a satellite of `My.resx` | -| A **classic** `*.Designer.cs` accessor sits in the same folder | That file is where the generator reads the namespace and the `ResourceManager` property it must reference | +Whether a file is neutral or a satellite is MSBuild's own culture detection, the one that decides +which files go into satellite assemblies. So a neutral file with a dot in its name, such as +`My.Strings.resx`, qualifies, while `Strings.de.resx` does not. + +No `.Designer.cs` accessor is needed. Files with a designer, files without one, and the files of a +project that uses the SDK's `GenerateResxSource` all qualify. -The accessor is the one Visual Studio's and Rider's classic resx tooling writes — the -`PublicResXFileCodeGenerator` or `ResXFileCodeGenerator` custom tool. The SDK's `GenerateResxSource` -alternative writes its accessor into `obj/`, where it is build output rather than a file the -generator can pair a `.resx` with, so a project using it gets no typed keys. +The generator learns each file's resource name - the name MSBuild embeds its resources under - from +the build, through the analyzer config file the SDK writes into `obj/`. The packages' build wiring +passes it; a `.resx` that reaches the generator any other way gets no keys and reports `RXLGEN003`. ## What becomes a key @@ -25,16 +26,24 @@ image, a colour, an icon — is skipped, because its value is not a string a loo Resource names that are not valid C# identifiers are sanitized into ones that are, and a collision that sanitizing would create is resolved by appending a number. The class is named after the file, -with `Keys` appended, and lands in the same namespace as the resource accessor. +with `Keys` appended and sanitized the same way: `My.Strings.resx` produces `My_StringsKeys`. ```csharp -// - for Resources/AppStrings.resx -namespace YourApp.Resources; +// - for Resources/AppStrings.resx, with or without AppStrings.Designer.cs +namespace MyApp.Resources; +/// Typed, compile-validated keys generated from AppStrings.resx. public static partial class AppStringsKeys { - public static readonly ResourceKey Greeting = new("Greeting", AppStrings.ResourceManager); - public static readonly ResourceKey WindowTitle = new("WindowTitle", AppStrings.ResourceManager); + /// + /// The resource manager of AppStrings.resx - the instance every key of this class uses. Register this + /// instance for search-all lookups, and pass it for scoped lookups. + /// + public static global::System.Resources.ResourceManager ResourceManager { get; } = + new("MyApp.Resources.AppStrings", typeof(AppStringsKeys).Assembly); + + public static readonly global::RentADeveloper.ResXLocalization.ResourceKey Greeting = new("Greeting", ResourceManager); + public static readonly global::RentADeveloper.ResXLocalization.ResourceKey WindowTitle = new("WindowTitle", ResourceManager); // …one ResourceKey per string entry, sorted by name. } ``` @@ -43,17 +52,83 @@ Each `ResourceKey` carries **both** the key name and the `ResourceManager` it be what makes a typed lookup direct: it never searches, and two files may use the same key name without colliding. +`ResourceManager` is the one instance of that file every key uses. Use it wherever the file's +resource manager is needed: + +```csharp +Localizer.Current.RegisterResourceManager(AppStringsKeys.ResourceManager); // search-all +Localizer.Current.Get("Greeting", resourceManager: AppStringsKeys.ResourceManager); // scoped +``` + +A resource key named `ResourceManager` gets a numeric suffix (`ResourceManager2`), like any other +name collision. The resource manager is created from the file's exact resource name, without +reflection, so the generated code is trimming and Native AOT safe. + The class is `partial`, so you can add members of your own to it in a file of your own. +## The namespace + +| The file | Namespace of the keys class | +| --- | --- | +| has a paired classic designer | the designer's namespace | +| has none, and sets `CustomToolNamespace` | its `CustomToolNamespace` | +| has none, and no `CustomToolNamespace` | the resource name without its last segment | + +A designer pairs when it is a `.Designer.cs` in the same folder with the same base name that declares +a type with a static `ResourceManager` property — the accessor Visual Studio's and Rider's classic resx +tooling writes. So a project with designers keeps its keys classes where they are, including with +`CustomToolNamespace`. + +Without a designer, `CustomToolNamespace` decides when the project sets it on the `.resx` - the same +setting the designer tools use for the designer's namespace: + +```xml + + + +``` + +It changes only the namespace: the resource manager still loads the name MSBuild embeds the resources +under. With a designer, the designer's namespace wins, which is normally the same value. A blank +`CustomToolNamespace` counts as not set. + +Otherwise the resource name decides. MSBuild names `Resources/AppStrings.resx` in a project +whose root namespace is `MyApp` `MyApp.Resources.AppStrings`, so the class lands in `MyApp.Resources`. +A `LogicalName` replaces that name: with `LogicalName="Totally.Custom.Name.resources"` the resource +manager loads `Totally.Custom.Name` and the class lands in `Totally.Custom`. Each namespace segment is +sanitized into a valid identifier (`event` becomes `@event`, `My-App` becomes `My_App`), and so is each +segment of a `CustomToolNamespace`; a resource name without a dot means the global namespace. + +| File, resource name | Namespace | Class | `ResourceManager` base name | +| --- | --- | --- | --- | +| `Resources/Strings.resx`, `N.Resources.Strings`, no designer | `N.Resources` | `StringsKeys` | `N.Resources.Strings` | +| same, designer declares `namespace Custom.Ns` | `Custom.Ns` | `StringsKeys` | `N.Resources.Strings` | +| same, no designer, `CustomToolNamespace=Texts.Custom` | `Texts.Custom` | `StringsKeys` | `N.Resources.Strings` | +| `Logical.resx`, `LogicalName=Totally.Custom.Name.resources` | `Totally.Custom` | `LogicalKeys` | `Totally.Custom.Name` | +| `My.Strings.resx`, `N.My.Strings` | `N.My` | `My_StringsKeys` | `N.My.Strings` | + +**WPF projects.** If the XAML build of a WPF project cannot find a keys class of a `.resx` without a +designer - CS0234 or CS0103, reported for a project named `__wpftmp` - the project +computes its `RootNamespace` in a way the build cannot map back to the real one, for example with +`ToUpper()`. Set a literal `RootNamespace`, or a `CustomToolNamespace` on the `.resx`. + +## Projects with designers + +Designers keep working, and the generator uses them for the namespace only. Their string properties, +such as `AppStrings.Greeting`, read in `CultureInfo.CurrentUICulture`, so they do not follow +`Localizer.CurrentCulture` or live language switching. Use the typed keys instead: +`Localizer.Current.Get(AppStringsKeys.Greeting)`. + ## Diagnostics | Id | Meaning | | --- | --- | -| `RXLGEN001` | An eligible `.resx` file is not well-formed XML. The diagnostic points at the file | -| `RXLGEN002` | An eligible `.resx` file has no recognizable classic accessor in its folder | +| `RXLGEN001` | A neutral `.resx` file is not well-formed XML. The diagnostic points at the file | +| `RXLGEN003` | A `.resx` file reached the generator without its MSBuild resource name, so no keys were generated. Include it as an `EmbeddedResource` of a project that references `ResXLocalization.Avalonia` or `ResXLocalization.WPF` | +| `RXLGEN004` | Two neutral `.resx` files produce the same keys class - for example two `Strings.resx` in different folders whose designers declare the same namespace. Both files report it and neither class is generated. Rename one of the files, or change its designer's namespace or its `CustomToolNamespace` | -`RXLGEN002` is the one to expect when a project uses `GenerateResxSource`, or when the `.Designer.cs` -file was deleted and the custom tool was never re-run. +A file reports at most one of them, checked in this order: `RXLGEN003`, then `RXLGEN004`, then +`RXLGEN001`. There is no `RXLGEN002`: a `.resx` no longer needs a designer. ## The compiler floor diff --git a/scripts/pre-commit-gate.ps1 b/scripts/pre-commit-gate.ps1 index 6bb87e3..c70e371 100644 --- a/scripts/pre-commit-gate.ps1 +++ b/scripts/pre-commit-gate.ps1 @@ -26,11 +26,11 @@ Run: pwsh -File scripts/verify-package-aot.ps1 -Pack Package consumers Trigger: any change to the package contents, the buildTransitive wiring, the - generator's compiler floor, or a dependency version. They install the packages + generator's output or compiler floor, or a dependency version. They install the packages the way a stranger would, which a project-referenced test cannot. Run: pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation - which packs and then runs all of them, on both of AvaloniaConsumer's - target frameworks. + which packs and then runs all of them on the pinned SDK, on both of + AvaloniaConsumer's target frameworks, and again on the .NET 8 SDK. .PARAMETER Fix Apply style, formatting and member ordering to the working tree before the checks, instead of only @@ -323,7 +323,7 @@ if ($failures.Count -gt 0) Write-Output 'PASSED. Not covered here, and not needed for every change:' Write-Output ' - the Native AOT gate, after a change to resource lookup, the generated keys or the packaging' Write-Output ' (pwsh -File scripts/verify-package-aot.ps1 -Pack)' -Write-Output ' - the package consumers, after a change to what the packages contain' +Write-Output ' - the package consumers on the pinned and the .NET 8 SDK, after a change to what the packages contain' Write-Output ' (pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation)' exit 0 diff --git a/scripts/pre-release-gate.ps1 b/scripts/pre-release-gate.ps1 index b186177..f1d8be8 100644 --- a/scripts/pre-release-gate.ps1 +++ b/scripts/pre-release-gate.ps1 @@ -7,7 +7,7 @@ Run this before pushing a release - or any branch you want CI to go green on. It is the pre-commit gate plus the jobs that gate a publish: the documentation build with warnings as errors, the pack with package validation, the Native AOT gate against the packed packages, and the package consumers against - those same packages. + those same packages - once on the SDK the repository pins and once on the .NET 8 SDK. It stops at the first failure. Later steps consume what earlier ones produce - the AOT gate and the consumers run against the packages the pack step wrote - so continuing past a failure would only @@ -24,14 +24,16 @@ publishes anything. The GitHub release Needs the tag and the GitHub API. The other operating system CI runs the Avalonia verification, the AOT gate and the minimum-SDK - consumers on Linux and on Windows. This runs whichever legs the machine - you are on can run, and says which ones it skipped. + consumers on Linux and on Windows. This runs only what the machine you + are on can run, and says what it skipped. The WPF jobs, off Windows The WPF projects do not load anywhere else, so on Linux or macOS the WPF build, the WPF tests and the WPF and combined consumers are not run. - The .NET-8-SDK-only leg CI installs the 8.0 SDK alone to prove the generator's Roslyn floor. - This machine resolves the SDK the root global.json pins, so the consumer - steps here prove the packages work - not that they work with nothing but - an 8.0 SDK installed. + An 8.0 SDK on its own CI's minimum-SDK job runs on a machine with only the .NET 8 SDK + installed. This gate also runs the consumers on an 8.0 SDK, selected + the same way, so that SDK's compiler and build targets are used. But + the SDK the root global.json pins is installed here too, so a pass here + does not prove the packages work on a machine that has only the .NET 8 + SDK. A dirty working tree is reported, not rejected: CI tests the commit you push, so anything uncommitted is untested by definition, but you may well still be iterating. @@ -52,6 +54,10 @@ .PARAMETER SkipDocumentation Skip the DocFX metadata and site build. It needs the docfx local tool. +.PARAMETER SkipMinimumSdk + Skip running the package consumers a second time on the .NET 8 SDK. That step needs an 8.0 SDK + installed next to the one the repository pins. + .PARAMETER Configuration Build configuration. Release by default, because that is what CI uses and what gets published. @@ -68,6 +74,7 @@ param( [Switch] $SkipNativeAot, [Switch] $SkipConsumers, [Switch] $SkipDocumentation, + [Switch] $SkipMinimumSdk, [String] $Configuration = 'Release' ) @@ -82,6 +89,7 @@ $onWindows = $IsWindows $solutionFileName = if ($onWindows) { 'ResXLocalization.slnx' } else { 'ResXLocalization.NonWindows.slnf' } $consumerDirectory = Join-Path $repositoryRoot 'tests/package-consumption' +$consumerSdkPin = Join-Path $consumerDirectory 'global.json' $packageOutput = Join-Path $repositoryRoot 'artifacts/packages' $docfxConfiguration = 'build/docfx/docfx.json' @@ -188,6 +196,25 @@ else Write-Output 'Clean.' } +# --- Consumer SDK pin --------------------------------------------------------------------------------- +# The step that runs the consumers on the .NET 8 SDK writes a global.json into the consumer directory and +# removes it again. One left behind - by an interrupted run, or by hand - would quietly choose the SDK for +# everything that runs from there: the Native AOT gate's publish and every consumer. An 8.0 SDK cannot +# build for net10.0, so the net10.0 runs would fail with a message that does not mention the pin. Checked +# first, so the failure names the cause. + +Invoke-Step 'Consumer SDK pin' { + if (Test-Path -LiteralPath $consumerSdkPin) + { + Write-Output "FAILED - $consumerSdkPin exists and would choose the SDK for the AOT gate and the consumers. Delete it and re-run." + $script:stepSucceeded = $false + + return + } + + Write-Output 'None.' +} + # --- Release identity --------------------------------------------------------------------------------- # CI verifies both of these immediately before it publishes. A tag that does not match the packed version # publishes stale packages or, thanks to --skip-duplicate, nothing at all; a missing changelog section @@ -416,6 +443,10 @@ Invoke-Step 'Pack' { Write-Output '' Write-Output 'NOT PACKED: ResXLocalization.WPF. Release packaging happens on Windows.' } + + # The consumers below build against the packages just written, under the same version as the last + # pack. A reused MSBuild node keeps evaluating the previous package's targets, so stop it here. + & dotnet build-server shutdown 2>&1 | Out-Null } # --- Native AOT gate ---------------------------------------------------------------------------------- @@ -460,29 +491,40 @@ Invoke-Step 'Native AOT gate' { # project-referenced test can see. NUGET_PACKAGES and RestoreConfigFile are overridden so a run cannot # quietly resolve a ResXLocalization assembly that did not come out of the packages just packed. -Invoke-Step 'Package consumers' { - if ($SkipConsumers) - { - Write-Output 'Skipped (-SkipConsumers).' - - return - } +function Invoke-PackageConsumers +{ + <# + Runs each consumer from the consumer directory - so a global.json there decides the SDK - and sets + $script:stepSucceeded to $false on the first one that fails. Each entry is @{ Name; Arguments }. + #> + param([Parameter(Mandatory)] [Object[]] $Consumers) $packageVersion = Get-DeclaredVersion $originalNuGetPackages = $env:NUGET_PACKAGES $consumerNuGetConfig = Join-Path $consumerDirectory 'nuget.config' - # AvaloniaConsumer multi-targets, so the framework is named explicitly - `dotnet run` refuses to - # guess, and a leg that guessed would not be the leg it claims to be. Both of the packages' target - # frameworks are exercised. WpfConsumer and CombinedConsumer target one framework each. - $consumers = @( - @{ Name = 'AvaloniaConsumer'; Framework = 'net8.0' } - @{ Name = 'AvaloniaConsumer'; Framework = 'net10.0' } - ) - if ($onWindows) + # NuGet resolves by version and not by content, so a ResXLocalization package an earlier run left in the + # consumers' cache would satisfy the reference instead of the one just packed - the Native AOT gate empties + # that cache, but it can be skipped. Remove our packages from it here, whatever ran before; the build + # server goes first because one of its nodes can still hold a package assembly open. NuGet extracts + # read-only, hence the attribute reset. + & dotnet build-server shutdown 2>&1 | Out-Null + + try + { + Get-ChildItem -LiteralPath (Join-Path $consumerDirectory '.packages') -Directory -Filter 'resxlocalization.*' -ErrorAction SilentlyContinue | + ForEach-Object { + Get-ChildItem -LiteralPath $_.FullName -Recurse -Force -File | ForEach-Object { $_.IsReadOnly = $false } + Remove-Item -LiteralPath $_.FullName -Recurse -Force + } + } + catch { - $consumers += @{ Name = 'WpfConsumer'; Framework = $null } - $consumers += @{ Name = 'CombinedConsumer'; Framework = $null } + Write-Output "FAILED - the cached ResXLocalization packages could not be removed: $($_.Exception.Message)" + Write-Output 'Running against them would test whatever was cached last. Close anything holding a file there and re-run.' + $script:stepSucceeded = $false + + return } Push-Location -LiteralPath $consumerDirectory @@ -490,15 +532,19 @@ Invoke-Step 'Package consumers' { { $env:NUGET_PACKAGES = Join-Path $consumerDirectory '.packages' - foreach ($consumer in $consumers) + foreach ($consumer in $Consumers) { $name = $consumer.Name - $framework = @(if ($consumer.Framework) { @('--framework', $consumer.Framework) } else { @() }) + $arguments = @($consumer.Arguments) Write-Output '' - Write-Output "--- $name$(if ($consumer.Framework) { " ($($consumer.Framework))" }) ---" + Write-Output "--- $((@($name) + $arguments) -join ' ') ---" - & dotnet run --project "$name/$name.csproj" -c $Configuration @framework ` + # A build left by the previous run - possibly by the other SDK - could let MSBuild and WPF's + # markup compiler decide that nothing changed and skip exactly the passes under test. + Remove-Item -LiteralPath (Join-Path $name 'bin'), (Join-Path $name 'obj') -Recurse -Force -ErrorAction SilentlyContinue + + & dotnet run --project "$name/$name.csproj" -c $Configuration @arguments ` -p:RestoreConfigFile=$consumerNuGetConfig -p:ResXLocalizationVersion=$packageVersion if ($LASTEXITCODE -ne 0) @@ -512,7 +558,7 @@ Invoke-Step 'Package consumers' { if (-not $onWindows) { Write-Output '' - Write-Output 'NOT RUN: WpfConsumer and CombinedConsumer. They need Windows.' + Write-Output 'NOT RUN: WpfConsumer, WpfDerivedNamespaceConsumer and CombinedConsumer. They need Windows.' } } finally @@ -522,6 +568,120 @@ Invoke-Step 'Package consumers' { } } +# The WPF consumers target one framework each. WpfDerivedNamespaceConsumer derives its RootNamespace from +# its project name; see the comment "WPF's temporary markup-compile pass" in +# build/ResXLocalization.Resx.targets. +$wpfConsumers = @( + @{ Name = 'WpfConsumer'; Arguments = @() } + @{ Name = 'WpfDerivedNamespaceConsumer'; Arguments = @() } + @{ Name = 'CombinedConsumer'; Arguments = @() } +) + +Invoke-Step 'Package consumers' { + if ($SkipConsumers) + { + Write-Output 'Skipped (-SkipConsumers).' + + return + } + + # AvaloniaConsumer targets net8.0 and net10.0, and `dotnet run` runs a project with several target + # frameworks only when one is named, so the consumer runs once per framework with `--framework`. That + # way both of the packages' target frameworks are exercised. + $consumers = @( + @{ Name = 'AvaloniaConsumer'; Arguments = @('--framework', 'net8.0') } + @{ Name = 'AvaloniaConsumer'; Arguments = @('--framework', 'net10.0') } + ) + + if ($onWindows) + { + $consumers += $wpfConsumers + } + + Invoke-PackageConsumers -Consumers $consumers +} + +# --- Package consumers on the .NET 8 SDK --------------------------------------------------------------- +# The same consumers, built by the oldest SDK the packages support - what CI's "Consume packages on .NET 8 +# SDK" job does, with the same temporary pin. The SDKs differ in more than the compiler - for example in +# WPF's temporary markup-compile pass, which only WpfConsumer built by the .NET 8 SDK gets wrong without +# the fix; see the comment "WPF's temporary markup-compile pass" in build/ResXLocalization.Resx.targets. + +Invoke-Step 'Package consumers (.NET 8 SDK)' { + if ($SkipConsumers -or $SkipMinimumSdk) + { + Write-Output "Skipped ($(if ($SkipConsumers) { '-SkipConsumers' } else { '-SkipMinimumSdk' }))." + + return + } + + if (-not (& dotnet --list-sdks | Where-Object { $_ -like '8.*' })) + { + Write-Output 'FAILED - no .NET 8 SDK is installed. Install one (see CONTRIBUTING.md), or pass -SkipMinimumSdk.' + $script:stepSucceeded = $false + + return + } + + if (Test-Path -LiteralPath $consumerSdkPin) + { + Write-Output "FAILED - $consumerSdkPin already exists; this step writes and removes its own. Delete it and re-run." + $script:stepSucceeded = $false + + return + } + + # The pin covers the consumer directory only: `dotnet` resolves global.json from the current directory + # upward, and Invoke-PackageConsumers runs from there. It is gitignored, and removed again below. + Set-Content -LiteralPath $consumerSdkPin -Value '{ "sdk": { "version": "8.0.100", "rollForward": "latestFeature" } }' + + try + { + Push-Location -LiteralPath $consumerDirectory + try + { + $activeSdk = & dotnet --version + } + finally + { + Pop-Location + } + + Write-Output "Active SDK: $activeSdk" + + if ($activeSdk -notlike '8.*') + { + Write-Output 'FAILED - the pin did not select an 8.0 SDK.' + $script:stepSucceeded = $false + + return + } + + # -p:TargetFrameworks as well as --framework: restore evaluates every entry in TargetFrameworks, and + # an 8.0 SDK cannot evaluate net10.0 (NETSDK1045). + $consumers = @(@{ Name = 'AvaloniaConsumer'; Arguments = @('--framework', 'net8.0', '-p:TargetFrameworks=net8.0') }) + + if ($onWindows) + { + $consumers += $wpfConsumers + } + + Invoke-PackageConsumers -Consumers $consumers + } + finally + { + # Never let a failed delete escape: the summary line must still be written, and the leftover is + # named so the next run's first step does not have to explain it. + Remove-Item -LiteralPath $consumerSdkPin -Force -ErrorAction SilentlyContinue + + if (Test-Path -LiteralPath $consumerSdkPin) + { + Write-Output "FAILED - the temporary pin $consumerSdkPin could not be removed. Delete it by hand." + $script:stepSucceeded = $false + } + } +} + # --- Summary ------------------------------------------------------------------------------------------ Write-Section 'Pre-release gate summary' diff --git a/scripts/verify-package-aot.ps1 b/scripts/verify-package-aot.ps1 index 50a07bb..5567f68 100644 --- a/scripts/verify-package-aot.ps1 +++ b/scripts/verify-package-aot.ps1 @@ -196,9 +196,9 @@ try # The build server is asked to stop before EVERY attempt, not once. It is the usual holder - an # MSBuild or ILC node from the previous run keeps package assemblies loaded - and when this gate - # runs both frameworks back to back, the first leg's nodes are still exiting while the second - # leg starts. One shutdown up front is not enough for that; measured, it needs a few seconds - # and another ask. + # runs both frameworks back to back, the nodes of the first framework's run are still exiting + # while the second run starts. One shutdown up front is not enough for that; measured, it needs a + # few seconds and another ask. $removed = $false for ($attempt = 1; $attempt -le 8; $attempt++) @@ -232,6 +232,12 @@ try exit 1 } } + else + { + # No cache to empty, so the loop above did not stop the build server. Stop it anyway: a reused + # MSBuild node keeps evaluating the targets of a previous pack of the same version. + & dotnet build-server shutdown 2>&1 | Out-Null + } # --- The artifact set, validated from the nuspec -------------------------------------------------- # diff --git a/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md index c00802b..b785b20 100644 --- a/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/ResXLocalization.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,2 +1,12 @@ ### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +RXLGEN003 | ResXLocalization.SourceGenerator | Warning | Reports a .resx whose MSBuild resource name did not reach the generator +RXLGEN004 | ResXLocalization.SourceGenerator | Warning | Reports two neutral .resx files that would produce the same keys class + +### Removed Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +RXLGEN002 | ResXLocalization.SourceGenerator | Warning | Reports a missing or unrecognized classic resource accessor diff --git a/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj b/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj index 2d3b0f3..daf0e6f 100644 --- a/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj +++ b/src/ResXLocalization.SourceGenerators/ResXLocalization.SourceGenerators.csproj @@ -14,10 +14,9 @@ - - - - + + + + + + + + + + diff --git a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml index bedea44..2c707ec 100644 --- a/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml +++ b/tests/package-consumption/AvaloniaConsumer/LocalizedView.axaml @@ -8,6 +8,10 @@ + + + diff --git a/tests/package-consumption/AvaloniaConsumer/Program.cs b/tests/package-consumption/AvaloniaConsumer/Program.cs index ba7d543..c7527b0 100644 --- a/tests/package-consumption/AvaloniaConsumer/Program.cs +++ b/tests/package-consumption/AvaloniaConsumer/Program.cs @@ -1,7 +1,10 @@ // End-to-end consumption check for the ResXLocalization.Avalonia NuGet package. Everything below must // arrive through the single PackageReference: the Core engine, the markup-extension assembly, and - -// proven at compile time by the StringsKeys and CatalogKeys classes - the packaged source generator plus -// the buildTransitive MSBuild wiring that feeds it. Exits non-zero on the first failed check. +// proven at compile time by the StringsKeys, CatalogKeys and DesignerlessKeys classes - the packaged +// source generator plus the buildTransitive MSBuild wiring that feeds it, including the MSBuild resource +// name a .resx without a designer needs (Designerless.resx), also when LogicalName replaces it +// (Logical.resx) and when the DependentUpon convention names it after a class (Conventional.resx). Exits +// non-zero on the first failed check. // // This program is also the assertion set the Native AOT gate runs. Everything a trimmer can break and a // JIT cannot is here: satellite discovery, resource lookup by name, the typed keys' captured resource @@ -12,7 +15,10 @@ using Avalonia.Controls; using Avalonia.Threading; using AvaloniaConsumer; +using AvaloniaConsumer.Convention; +using AvaloniaConsumer.Custom; using AvaloniaConsumer.Resources; +using AvaloniaConsumer.Texts; using RentADeveloper.ResXLocalization; using RentADeveloper.ResXLocalization.Avalonia; @@ -47,6 +53,23 @@ void Check(string description, string actual, string expected) ); Check("search-all cannot reach the unregistered manager", Localizer.Current.Get("ScopedOnly"), "!ScopedOnly!"); Check("enum localization by convention", Localizer.Current.Get(ConsumerSortOrder.Ascending), "Ascending"); +Check("typed key without a designer", Localizer.Current.Get(DesignerlessKeys.Title), "Designer-less"); +Check( + "scoped lookup into the generated resource manager", + Localizer.Current.Get("Title", resourceManager: DesignerlessKeys.ResourceManager), + "Designer-less" +); +Check("typed key whose resource name is a LogicalName", Localizer.Current.Get(LogicalKeys.Title), "Logical name"); +Check( + "typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "By convention" +); +Check( + "typed key in the namespace CustomToolNamespace names", + Localizer.Current.Get(NamedKeys.Title), + "Custom tool namespace" +); Check( "named arguments from an object's properties", Localizer.Current.Get(StringsKeys.ArgumentPair, MessageArguments.FromObject(new { A = "x", B = 2 })), @@ -60,6 +83,7 @@ void Check(string description, string actual, string expected) Check("compiled XAML typed key", localizedView.GreetingText.Text ?? "", "Hello from the package!"); Check("compiled XAML scoped lookup", localizedView.ScopedText.Text ?? "", "Scoped to the catalog"); Check("compiled XAML enum by convention", localizedView.SortOrderText.Text ?? "", "Ascending"); +Check("compiled XAML typed key without a designer", localizedView.DesignerlessText.Text ?? "", "Designer-less"); Check( "compiled XAML composite format, no argument set", localizedView.InvitedText.Text ?? "", @@ -82,8 +106,34 @@ void Check(string description, string actual, string expected) Check("live switch to de (satellite)", Localizer.Current.Get(StringsKeys.Greeting), "Hallo aus dem Paket!"); Check("live switch, enum by convention", Localizer.Current.Get(ConsumerSortOrder.Ascending), "Aufsteigend"); +Check("live switch, typed key without a designer", Localizer.Current.Get(DesignerlessKeys.Title), "Ohne Designer"); +Check( + "live switch, scoped lookup into the generated resource manager", + Localizer.Current.Get("Title", resourceManager: DesignerlessKeys.ResourceManager), + "Ohne Designer" +); +Check( + "live switch, typed key whose resource name is a LogicalName", + Localizer.Current.Get(LogicalKeys.Title), + "Logischer Name" +); +Check( + "live switch, typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "Nach Konvention" +); +Check( + "live switch, typed key in the namespace CustomToolNamespace names", + Localizer.Current.Get(NamedKeys.Title), + "Eigener Namespace" +); Check("compiled XAML live switch", localizedView.GreetingText.Text ?? "", "Hallo aus dem Paket!"); Check("compiled XAML live switch, enum", localizedView.SortOrderText.Text ?? "", "Aufsteigend"); +Check( + "compiled XAML live switch, typed key without a designer", + localizedView.DesignerlessText.Text ?? "", + "Ohne Designer" +); Check( "compiled XAML live switch, composite format keeps its argument", localizedView.InvitedText.Text ?? "", diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.cs b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.cs new file mode 100644 index 0000000..0173c80 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.cs @@ -0,0 +1,5 @@ +// Not used by the program. Its presence next to Conventional.resx is what makes MSBuild name the +// embedded resources after this class (the DependentUpon convention), not after the folder. +namespace AvaloniaConsumer.Convention; + +internal static class Conventional { } diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.de.resx new file mode 100644 index 0000000..90c3b39 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Nach Konvention + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.resx new file mode 100644 index 0000000..09f5a54 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Conventional.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + By convention + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.de.resx new file mode 100644 index 0000000..2123c7f --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ohne Designer + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.resx new file mode 100644 index 0000000..6b8cc73 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Designerless.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Designer-less + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Logical.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.de.resx new file mode 100644 index 0000000..65f59fc --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logischer Name + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Logical.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.resx new file mode 100644 index 0000000..66c21e4 --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Logical.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logical name + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Named.de.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Named.de.resx new file mode 100644 index 0000000..278445e --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Named.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Eigener Namespace + + diff --git a/tests/package-consumption/AvaloniaConsumer/Resources/Named.resx b/tests/package-consumption/AvaloniaConsumer/Resources/Named.resx new file mode 100644 index 0000000..f2d965d --- /dev/null +++ b/tests/package-consumption/AvaloniaConsumer/Resources/Named.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Custom tool namespace + + diff --git a/tests/package-consumption/README.md b/tests/package-consumption/README.md index 7f96417..afaf685 100644 --- a/tests/package-consumption/README.md +++ b/tests/package-consumption/README.md @@ -4,15 +4,23 @@ These package-only apps consume the **packed NuGet packages** (not project refer only place the most fragile parts of the packaging are exercised end to end: - the `buildTransitive/.targets` auto-import that feeds `.resx` + `.Designer.cs` files - to the source generator, + to the source generator, together with the MSBuild resource name of each `.resx` that every keys + class is created from - for a file without a designer (`Resources/Designerless.resx`), a name + replaced by `LogicalName` (`Logical.resx`) and one the `DependentUpon` convention takes from a class + (`Conventional.resx` next to `Conventional.cs`), in a multi-targeted project (`AvaloniaConsumer`) + and through WPF's temporary markup-compile pass (`WpfConsumer` with the SDK-default `RootNamespace`, + `WpfDerivedNamespaceConsumer` with one derived from its project name and a `LogicalName` and a + `CustomToolNamespace` built from it - see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`), and a `CustomToolNamespace` that names the + namespace of a keys class without a designer (`Named.resx`), - the source generator shipped as `analyzers/dotnet/cs`, - the transitive `ResXLocalization.Core` package, - the `net8.0` target of the multi-targeted packages, - the satellite-assembly flow of a real consumer. -The Avalonia and WPF apps compile real framework XAML against a generated `StringsKeys` class, -instantiate the view, and assert rendered live language switching. `CombinedConsumer` references -both UI packages and proves that one Core assembly is resolved. Every app exits non-zero on failure. +The Avalonia and WPF apps compile real framework XAML against the generated `StringsKeys` and +`DesignerlessKeys` classes, instantiate the view, and assert rendered live language switching. +`CombinedConsumer` references both UI packages and proves that one Core assembly is resolved. Every +app exits non-zero on failure. The folder has its own `Directory.Build.props`/`.targets`/`Directory.Packages.props` boundary so none of the repository's in-repo wiring leaks in, and its own isolated package cache (`.packages`). @@ -25,10 +33,19 @@ dotnet pack src/ResXLocalization.Core/ResXLocalization.Core.csproj -c Release -o dotnet pack src/ResXLocalization.Avalonia/ResXLocalization.Avalonia.csproj -c Release -o artifacts/packages dotnet pack src/ResXLocalization.WPF/ResXLocalization.WPF.csproj -c Release -o artifacts/packages # Windows only +# A reused MSBuild node keeps evaluating the previously packed targets of the same version; stop it. +dotnet build-server shutdown + dotnet run --project tests/package-consumption/AvaloniaConsumer/AvaloniaConsumer.csproj -c Release dotnet run --project tests/package-consumption/WpfConsumer/WpfConsumer.csproj -c Release # Windows only +dotnet run --project tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj -c Release # Windows only dotnet run --project tests/package-consumption/CombinedConsumer/CombinedConsumer.csproj -c Release # Windows only ``` After re-packing, delete `tests/package-consumption/.packages` so NuGet picks up the new build of the same version number. + +The consumers also have to work when built by the .NET 8 SDK, which differs from the pinned one in more +than its compiler - for example in WPF's temporary markup-compile pass. +`pwsh -File scripts/pre-release-gate.ps1` runs them a second time on an 8.0 SDK, as CI does: it writes a +temporary `global.json` into this folder, which is gitignored, and removes it again. diff --git a/tests/package-consumption/WpfConsumer/LocalizedView.xaml b/tests/package-consumption/WpfConsumer/LocalizedView.xaml index 5f0b1de..c0fed1c 100644 --- a/tests/package-consumption/WpfConsumer/LocalizedView.xaml +++ b/tests/package-consumption/WpfConsumer/LocalizedView.xaml @@ -3,5 +3,15 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:l="clr-namespace:RentADeveloper.ResXLocalization.WPF;assembly=ResXLocalization.WPF" xmlns:resources="clr-namespace:WpfConsumer.Resources"> - + + + + + + diff --git a/tests/package-consumption/WpfConsumer/Program.cs b/tests/package-consumption/WpfConsumer/Program.cs index 73f68d5..892b0cd 100644 --- a/tests/package-consumption/WpfConsumer/Program.cs +++ b/tests/package-consumption/WpfConsumer/Program.cs @@ -1,8 +1,9 @@ using System.Globalization; -using System.Windows.Controls; using System.Windows.Threading; using RentADeveloper.ResXLocalization; using RentADeveloper.ResXLocalization.WPF; +using WpfConsumer.Convention; +using WpfConsumer.Custom; using WpfConsumer.Resources; namespace WpfConsumer; @@ -36,15 +37,46 @@ void Check(String description, String actual, String expected) "Hello from the package!" ); Check("search-all lookup", Localizer.Current.Get("Greeting"), "Hello from the package!"); + Check("typed key without a designer", Localizer.Current.Get(DesignerlessKeys.Title), "Designer-less"); + Check( + "typed key whose resource name is a LogicalName", + Localizer.Current.Get(LogicalKeys.Title), + "Logical name" + ); + Check( + "typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "By convention" + ); var localizedView = new LocalizedView(); - var localizedText = (TextBlock)localizedView.Content; - Check("compiled XAML initial value", localizedText.Text, "Hello from the package!"); + Check("compiled XAML initial value", localizedView.GreetingText.Text, "Hello from the package!"); + Check("compiled XAML typed key without a designer", localizedView.DesignerlessText.Text, "Designer-less"); Localizer.Current.CurrentCulture = new CultureInfo("de"); Dispatcher.CurrentDispatcher.Invoke(static () => { }, DispatcherPriority.Background); Check("live switch to de (satellite)", Localizer.Current.Get(StringsKeys.Greeting), "Hallo aus dem Paket!"); - Check("compiled XAML live switch", localizedText.Text, "Hallo aus dem Paket!"); + Check("compiled XAML live switch", localizedView.GreetingText.Text, "Hallo aus dem Paket!"); + Check( + "live switch, typed key without a designer", + Localizer.Current.Get(DesignerlessKeys.Title), + "Ohne Designer" + ); + Check( + "compiled XAML live switch, typed key without a designer", + localizedView.DesignerlessText.Text, + "Ohne Designer" + ); + Check( + "live switch, typed key whose resource name is a LogicalName", + Localizer.Current.Get(LogicalKeys.Title), + "Logischer Name" + ); + Check( + "live switch, typed key named by the DependentUpon convention", + Localizer.Current.Get(ConventionalKeys.Title), + "Nach Konvention" + ); var cultures = Localizer.Current.GetAvailableCultures(); Check( diff --git a/tests/package-consumption/WpfConsumer/Resources/Conventional.cs b/tests/package-consumption/WpfConsumer/Resources/Conventional.cs new file mode 100644 index 0000000..d460452 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Conventional.cs @@ -0,0 +1,5 @@ +// Not used by the program. Its presence next to Conventional.resx is what makes MSBuild name the +// embedded resources after this class (the DependentUpon convention), not after the folder. +namespace WpfConsumer.Convention; + +internal static class Conventional { } diff --git a/tests/package-consumption/WpfConsumer/Resources/Conventional.de.resx b/tests/package-consumption/WpfConsumer/Resources/Conventional.de.resx new file mode 100644 index 0000000..90c3b39 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Conventional.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Nach Konvention + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Conventional.resx b/tests/package-consumption/WpfConsumer/Resources/Conventional.resx new file mode 100644 index 0000000..09f5a54 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Conventional.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + By convention + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Designerless.de.resx b/tests/package-consumption/WpfConsumer/Resources/Designerless.de.resx new file mode 100644 index 0000000..2123c7f --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Designerless.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ohne Designer + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Designerless.resx b/tests/package-consumption/WpfConsumer/Resources/Designerless.resx new file mode 100644 index 0000000..6b8cc73 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Designerless.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Designer-less + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Logical.de.resx b/tests/package-consumption/WpfConsumer/Resources/Logical.de.resx new file mode 100644 index 0000000..65f59fc --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Logical.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logischer Name + + diff --git a/tests/package-consumption/WpfConsumer/Resources/Logical.resx b/tests/package-consumption/WpfConsumer/Resources/Logical.resx new file mode 100644 index 0000000..66c21e4 --- /dev/null +++ b/tests/package-consumption/WpfConsumer/Resources/Logical.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logical name + + diff --git a/tests/package-consumption/WpfConsumer/WpfConsumer.csproj b/tests/package-consumption/WpfConsumer/WpfConsumer.csproj index 8000d0a..36285ba 100644 --- a/tests/package-consumption/WpfConsumer/WpfConsumer.csproj +++ b/tests/package-consumption/WpfConsumer/WpfConsumer.csproj @@ -19,4 +19,11 @@ + + + + + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml new file mode 100644 index 0000000..c9b28ab --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml @@ -0,0 +1,12 @@ + + + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml.cs b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml.cs new file mode 100644 index 0000000..36c6fc6 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/LocalizedView.xaml.cs @@ -0,0 +1,8 @@ +using System.Windows.Controls; + +namespace Derived.WpfDerivedNamespaceConsumer; + +public partial class LocalizedView : UserControl +{ + public LocalizedView() => this.InitializeComponent(); +} diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Program.cs b/tests/package-consumption/WpfDerivedNamespaceConsumer/Program.cs new file mode 100644 index 0000000..92fc788 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Program.cs @@ -0,0 +1,83 @@ +// End-to-end check for a WPF consumer whose RootNamespace is derived from its project name +// (Derived.$(MSBuildProjectName)). WPF compiles the XAML in a temporary copy of the project with a +// different name; the packaged build wiring must give the keys class of a .resx without a designer the +// same namespace there as in the real build - also for a LogicalName (Logical.resx) and a +// CustomToolNamespace (Named.resx) built from that RootNamespace - or this program and its XAML do not +// compile. See the comment "WPF's temporary markup-compile pass" in build/ResXLocalization.Resx.targets. +// Exits non-zero on the first failed check. + +using System.Globalization; +using System.Windows.Threading; +using Derived.WpfDerivedNamespaceConsumer.Custom; +using Derived.WpfDerivedNamespaceConsumer.Resources; +using Derived.WpfDerivedNamespaceConsumer.Texts; +using RentADeveloper.ResXLocalization; + +namespace Derived.WpfDerivedNamespaceConsumer; + +internal static class Program +{ + [STAThread] + private static int Main() + { + var failures = 0; + + void Check(string description, string actual, string expected) + { + if (actual == expected) + { + Console.WriteLine($"PASS {description}"); + } + else + { + Console.Error.WriteLine($"FAIL {description}: expected \"{expected}\", got \"{actual}\""); + failures++; + } + } + + Localizer.Current.CurrentCulture = new CultureInfo("en"); + + Check("typed key in the derived namespace", Localizer.Current.Get(DesignerlessKeys.Title), "Designer-less"); + Check( + "typed key whose LogicalName is built from RootNamespace", + Localizer.Current.Get(LogicalKeys.Title), + "Logical name" + ); + Check( + "typed key whose CustomToolNamespace is built from RootNamespace", + Localizer.Current.Get(NamedKeys.Title), + "Custom tool namespace" + ); + + var localizedView = new LocalizedView(); + Check("compiled XAML typed key in the derived namespace", localizedView.DesignerlessText.Text, "Designer-less"); + + Localizer.Current.CurrentCulture = new CultureInfo("de"); + Dispatcher.CurrentDispatcher.Invoke(static () => { }, DispatcherPriority.Background); + Check( + "live switch, typed key in the derived namespace", + Localizer.Current.Get(DesignerlessKeys.Title), + "Ohne Designer" + ); + Check("compiled XAML live switch", localizedView.DesignerlessText.Text, "Ohne Designer"); + Check( + "live switch, typed key whose LogicalName is built from RootNamespace", + Localizer.Current.Get(LogicalKeys.Title), + "Logischer Name" + ); + Check( + "live switch, typed key whose CustomToolNamespace is built from RootNamespace", + Localizer.Current.Get(NamedKeys.Title), + "Eigener Namespace" + ); + + if (failures > 0) + { + Console.Error.WriteLine($"{failures} package consumption check(s) FAILED."); + return 1; + } + + Console.WriteLine("All package consumption checks passed."); + return 0; + } +} diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.de.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.de.resx new file mode 100644 index 0000000..2123c7f --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ohne Designer + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.resx new file mode 100644 index 0000000..6b8cc73 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Designerless.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Designer-less + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.de.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.de.resx new file mode 100644 index 0000000..65f59fc --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logischer Name + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.resx new file mode 100644 index 0000000..66c21e4 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Logical.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Logical name + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.de.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.de.resx new file mode 100644 index 0000000..278445e --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.de.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Eigener Namespace + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.resx b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.resx new file mode 100644 index 0000000..f2d965d --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/Resources/Named.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Custom tool namespace + + diff --git a/tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj b/tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj new file mode 100644 index 0000000..48da8d5 --- /dev/null +++ b/tests/package-consumption/WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj @@ -0,0 +1,31 @@ + + + + Exe + net8.0-windows + true + enable + enable + en;de + + Derived.$(MSBuildProjectName) + + 1.0.0 + + + + + + + + + + + + + + +