diff --git a/readme.md b/readme.md index 1ba548c9..63af5ca0 100644 --- a/readme.md +++ b/readme.md @@ -107,6 +107,22 @@ When the `.nupkg` is created, these includes are resolved automatically so you k minimum. Nested includes are also supported (i.e. `footer.md` might in turn include a `sponsors.md` file or a fragment of it). +## Replacement Tokens + +NuGetizer supports all the [replacement tokens](https://learn.microsoft.com/en-us/nuget/reference/nuspec#replacement-tokens) +provided by NuGet, such as `$id$`, `$version$`, `$author$` and so on. Replacements are applied to license +file, readme file (post-inclusions, if any) and string-based properties such as description, title and summary. + +Morever, this replacement mechanism is extensible via MSBuild items `@(PackageReplacementToken)`, for example: + +```xml + + + +``` + +The newly added token can be used (case-insensitively) in your license file or readme file as `$company$`. + ## dotnet-nugetize Carefully tweaking your packages until they look exactly the way you want them should not be a tedious and slow process. Even requiring your project to be built between changes can be costly and reduce the speed at which you can iterate on the packaging aspects of the project. Also, generating the final `.nupkg`, opening it in a tool and inspecting its content, is also not ideal for rapid iteration. diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 00000000..640fe69b --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1 @@ +dotnet_diagnostic.IDE1100.severity = none diff --git a/src/NuGetizer.Tasks/CreatePackage.cs b/src/NuGetizer.Tasks/CreatePackage.cs index 69d5f866..4f55cd3c 100644 --- a/src/NuGetizer.Tasks/CreatePackage.cs +++ b/src/NuGetizer.Tasks/CreatePackage.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Text.RegularExpressions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NuGet.Frameworks; @@ -21,7 +22,9 @@ public class CreatePackage : Task public ITaskItem Manifest { get; set; } [Required] - public ITaskItem[] Contents { get; set; } = Array.Empty(); + public ITaskItem[] Contents { get; set; } = []; + + public ITaskItem[] ReplacementTokens { get; set; } = []; [Required] public string TargetPath { get; set; } @@ -39,6 +42,8 @@ public class CreatePackage : Task public ITaskItem OutputPackage { get; set; } Manifest manifest; + Dictionary tokens; + Regex tokensExpr; public override bool Execute() { @@ -68,13 +73,17 @@ public override bool Execute() } } + public Manifest Execute(Stream output) => Execute(output, out _); + // Implementation for testing to avoid I/O - public Manifest Execute(Stream output) + public Manifest Execute(Stream output, out Manifest manifest) { GeneratePackage(output); + manifest = this.manifest; output.Seek(0, SeekOrigin.Begin); using var reader = new PackageArchiveReader(output); + return reader.GetManifest(); } @@ -92,13 +101,13 @@ public Manifest CreateManifest() metadata.DevelopmentDependency = true; if (Manifest.TryGetMetadata("Title", out var title)) - metadata.Title = title.TrimIndent(); + metadata.Title = ReplaceTokens(title.TrimIndent()); if (Manifest.TryGetMetadata("Description", out var description)) - metadata.Description = description.TrimIndent(); + metadata.Description = ReplaceTokens(description.TrimIndent()); if (Manifest.TryGetMetadata("Summary", out var summary)) - metadata.Summary = summary.TrimIndent(); + metadata.Summary = ReplaceTokens(summary.TrimIndent()); if (Manifest.TryGetMetadata("Readme", out var readme)) metadata.Readme = readme; @@ -107,17 +116,17 @@ public Manifest CreateManifest() metadata.Language = language; if (Manifest.TryGetMetadata("Copyright", out var copyright)) - metadata.Copyright = copyright; + metadata.Copyright = ReplaceTokens(copyright); if (Manifest.TryGetBoolMetadata("RequireLicenseAcceptance", out var requireLicenseAcceptance) && requireLicenseAcceptance) metadata.RequireLicenseAcceptance = requireLicenseAcceptance; if (!string.IsNullOrEmpty(Manifest.GetMetadata("Authors"))) - metadata.Authors = Manifest.GetMetadata("Authors").Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + metadata.Authors = Manifest.GetMetadata("Authors").Split([','], StringSplitOptions.RemoveEmptyEntries); if (!string.IsNullOrEmpty(Manifest.GetMetadata("Owners"))) - metadata.Owners = Manifest.GetMetadata("Owners").Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + metadata.Owners = Manifest.GetMetadata("Owners").Split([','], StringSplitOptions.RemoveEmptyEntries); if (!string.IsNullOrEmpty(Manifest.GetMetadata("LicenseUrl"))) metadata.SetLicenseUrl(Manifest.GetMetadata("LicenseUrl")); @@ -171,7 +180,7 @@ public Manifest CreateManifest() metadata.Icon = icon; if (Manifest.TryGetMetadata("ReleaseNotes", out var releaseNotes)) - metadata.ReleaseNotes = releaseNotes.TrimIndent(); + metadata.ReleaseNotes = ReplaceTokens(releaseNotes.TrimIndent()); if (Manifest.TryGetMetadata("Tags", out var tags)) metadata.Tags = tags; @@ -190,6 +199,21 @@ public Manifest CreateManifest() return manifest; } + string? ReplaceTokens(string? text) + { + tokens ??= ReplacementTokens + .Select(x => (x.ItemSpec.ToLowerInvariant(), x.GetMetadata("Value"))) + .GroupBy(t => t.Item1) + .ToDictionary(g => g.Key, g => g.Last().Item2); + + tokensExpr ??= new Regex(@"\$(" + string.Join("|", tokens.Keys.Select(Regex.Escape)) + @")\$", RegexOptions.IgnoreCase); + + if (string.IsNullOrEmpty(text) || tokens.Count == 0) + return text; + + return tokensExpr.Replace(text, match => tokens[match.Groups[1].Value.ToLower()]); + } + void GeneratePackage(Stream output = null) { manifest ??= CreateManifest(); @@ -205,10 +229,27 @@ void GeneratePackage(Stream output = null) File.Exists(readmeFile.Source)) { // replace readme with includes replaced. - var replaced = IncludesResolver.Process(readmeFile.Source, message => Log.LogWarningCode("NG001", message)); - var temp = Path.GetTempFileName(); - File.WriteAllText(temp, replaced); - readmeFile.Source = temp; + var replaced = ReplaceTokens(IncludesResolver.Process(readmeFile.Source, message => Log.LogWarningCode("NG001", message))); + if (!replaced.Equals(File.ReadAllText(readmeFile.Source), StringComparison.Ordinal)) + { + var temp = Path.GetTempFileName(); + File.WriteAllText(temp, replaced); + readmeFile.Source = temp; + } + } + + if (manifest.Metadata.LicenseMetadata?.Type == LicenseType.File && + manifest.Files.FirstOrDefault(f => Path.GetFileName(f.Target) == manifest.Metadata.LicenseMetadata.License) is ManifestFile licenseFile && + File.Exists(licenseFile.Source)) + { + // replace readme with includes replaced. + var replaced = ReplaceTokens(IncludesResolver.Process(licenseFile.Source, message => Log.LogWarningCode("NG001", message))); + if (!replaced.Equals(File.ReadAllText(licenseFile.Source), StringComparison.Ordinal)) + { + var temp = Path.GetTempFileName(); + File.WriteAllText(temp, replaced); + licenseFile.Source = temp; + } } builder.Files.AddRange(manifest.Files.Select(file => diff --git a/src/NuGetizer.Tasks/NuGetizer.Shared.targets b/src/NuGetizer.Tasks/NuGetizer.Shared.targets index a99bf2bc..abce9055 100644 --- a/src/NuGetizer.Tasks/NuGetizer.Shared.targets +++ b/src/NuGetizer.Tasks/NuGetizer.Shared.targets @@ -320,12 +320,23 @@ Copyright (c) .NET Foundation. All rights reserved. + + + + + + + + + + <_NuspecFile>%(NuspecFile.FullPath) + TargetPath="@(PackageTargetPath->'%(FullPath)')" + ReplacementTokens="@(PackageReplacementToken)"> diff --git a/src/NuGetizer.Tests/CreatePackageTests.cs b/src/NuGetizer.Tests/CreatePackageTests.cs index ed9b219f..fd6a3b71 100644 --- a/src/NuGetizer.Tests/CreatePackageTests.cs +++ b/src/NuGetizer.Tests/CreatePackageTests.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using Microsoft.VisualStudio.TestPlatform.Utilities; using NuGet.Frameworks; using NuGet.Packaging; using NuGet.Packaging.Core; @@ -33,7 +35,7 @@ public CreatePackageTests(ITestOutputHelper output) { { MetadataName.PackageId, "package" }, { MetadataName.Version, "1.0.0" }, - { "Title", "title" }, + { "Title", "title $id$" }, { "Description", """ @@ -60,7 +62,13 @@ New paragraph preserved. { "ReleaseNotes", "release notes" }, { "MinClientVersion", "3.4.0" }, { "PackageTypes", PackageType.Dependency.Name } - }) + }), + ReplacementTokens = + [ + new TaskItem("Id", new Dictionary { ["Value"] = "package" }), + new TaskItem("Version", new Dictionary { ["Value"] = "1.0.0" }), + new TaskItem("Product", new Dictionary { ["Value"] = "NuGetizer" }), + ] }; #if RELEASE @@ -70,9 +78,16 @@ New paragraph preserved. #endif } - Manifest ExecuteTask() => createPackage ? - task.Execute(new MemoryStream()) : - task.CreateManifest(); + Manifest ExecuteTask() => ExecuteTask(out _); + + Manifest ExecuteTask(out Manifest sourceManifest) + { + if (createPackage) + return task.Execute(new MemoryStream(), out sourceManifest); + + sourceManifest = null; + return task.CreateManifest(); + } [Fact] public void when_output_path_not_exists_then_creates_it() @@ -118,7 +133,7 @@ public void when_creating_package_then_contains_all_metadata() Assert.Equal(task.Manifest.GetMetadata("PackageId"), metadata.Id); Assert.Equal(task.Manifest.GetMetadata("Version"), metadata.Version.ToString()); - Assert.Equal(task.Manifest.GetMetadata("Title"), metadata.Title); + Assert.Equal("title package", metadata.Title); Assert.Equal(task.Manifest.GetMetadata("Summary"), metadata.Summary); Assert.Equal(task.Manifest.GetMetadata("Language"), metadata.Language); Assert.Equal(task.Manifest.GetMetadata("Copyright"), metadata.Copyright); @@ -217,6 +232,74 @@ public void when_creating_package_has_license_file_then_manifest_has_license() Assert.Null(metadata.LicenseMetadata.WarningsAndErrors); } + [Fact] + public void when_license_file_has_tokens_then_replacements_applied() + { + var content = Path.GetTempFileName(); + File.WriteAllText(content, "EULA for $product$ ($id$)."); + task.Contents = new[] + { + new TaskItem(content, new Metadata + { + { MetadataName.PackageId, task.Manifest.GetMetadata("Id") }, + { MetadataName.PackFolder, PackFolderKind.None }, + { MetadataName.PackagePath, "license.txt" } + }), + }; + + task.Manifest.SetMetadata("LicenseUrl", ""); + task.Manifest.SetMetadata("LicenseFile", "license.txt"); + + createPackage = true; + ExecuteTask(out var manifest); + + Assert.NotNull(manifest); + + Assert.Equal("license.txt", manifest.Metadata.LicenseMetadata.License); + Assert.Equal(LicenseType.File, manifest.Metadata.LicenseMetadata.Type); + + var file = manifest.Files.FirstOrDefault(f => Path.GetFileName(f.Target) == manifest.Metadata.LicenseMetadata.License); + Assert.NotNull(file); + Assert.True(File.Exists(file.Source)); + + var eula = File.ReadAllText(file.Source); + + Assert.Equal("EULA for NuGetizer (package).", eula); + } + + [Fact] + public void when_readme_has_include_and_tokens_then_replacements_applied() + { + var content = Path.GetTempFileName(); + File.WriteAllText(content, ""); + task.Contents = new[] + { + new TaskItem(content, new Metadata + { + { MetadataName.PackageId, task.Manifest.GetMetadata("Id") }, + { MetadataName.PackFolder, PackFolderKind.None }, + { MetadataName.PackagePath, "readme.md" } + }), + }; + + task.Manifest.SetMetadata("Readme", "readme.md"); + + createPackage = true; + ExecuteTask(out var manifest); + + Assert.NotNull(manifest); + + Assert.Equal("readme.md", manifest.Metadata.Readme); + + var file = manifest.Files.FirstOrDefault(f => Path.GetFileName(f.Target) == manifest.Metadata.Readme); + Assert.NotNull(file); + Assert.True(File.Exists(file.Source)); + + var readme = File.ReadAllText(file.Source); + + Assert.Contains("NuGetizer", readme); + } + [Fact] public void when_creating_package_with_simple_dependency_then_contains_dependency_group() { diff --git a/src/NuGetizer.Tests/given_a_library.cs b/src/NuGetizer.Tests/given_a_library.cs index b2905dd8..f84f7f10 100644 --- a/src/NuGetizer.Tests/given_a_library.cs +++ b/src/NuGetizer.Tests/given_a_library.cs @@ -51,5 +51,23 @@ public void when_pack_excludes_additional_items_then_contains_only_matching_file Assert.Single(compile); } + + [Fact] + public void when_packing_performs_token_replacement() + { + var result = Builder.BuildScenario(nameof(given_a_library), + new { PackCompile = "true", PackOnlyApi = "true" }, + target: "Build,GetPackageContents,Pack"); + + Assert.True(result.BuildResult.HasResultsForTarget("GetPackageContents")); + + var items = result.BuildResult.ResultsByTarget["GetPackageContents"]; + var compile = items.Items.Where(item => item.Matches(new + { + BuildAction = "Compile", + })).ToArray(); + + Assert.Single(compile); + } } }