Skip to content

Commit e113040

Browse files
committed
Add support for replacement tokens in key files and props
See https://learn.microsoft.com/en-us/nuget/reference/nuspec#replacement-tokens Fixes #642
1 parent f019f20 commit e113040

6 files changed

Lines changed: 190 additions & 20 deletions

File tree

readme.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,22 @@ When the `.nupkg` is created, these includes are resolved automatically so you k
107107
minimum. Nested includes are also supported (i.e. `footer.md` might in turn include a `sponsors.md` file or
108108
a fragment of it).
109109

110+
## Replacement Tokens
111+
112+
NuGetizer supports all the [replacement tokens](https://learn.microsoft.com/en-us/nuget/reference/nuspec#replacement-tokens)
113+
provided by NuGet, such as `$id$`, `$version$`, `$author$` and so on. Replacements are applied to license
114+
file, readme file (post-inclusions, if any) and string-based properties such as description, title and summary.
115+
116+
Morever, this replacement mechanism is extensible via MSBuild items `@(PackageReplacementToken)`, for example:
117+
118+
```xml
119+
<ItemGroup>
120+
<PackageReplacementToken Include="Company" Value="$(Company)" />
121+
</ItemGroup>
122+
```
123+
124+
The newly added token can be used (case-insensitively) in your license file or readme file as `$company$`.
125+
110126
## dotnet-nugetize
111127

112128
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.

src/.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dotnet_diagnostic.IDE1100.severity = none

src/NuGetizer.Tasks/CreatePackage.cs

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.IO;
55
using System.Linq;
66
using System.Security.Cryptography;
7+
using System.Text.RegularExpressions;
78
using Microsoft.Build.Framework;
89
using Microsoft.Build.Utilities;
910
using NuGet.Frameworks;
@@ -21,7 +22,9 @@ public class CreatePackage : Task
2122
public ITaskItem Manifest { get; set; }
2223

2324
[Required]
24-
public ITaskItem[] Contents { get; set; } = Array.Empty<ITaskItem>();
25+
public ITaskItem[] Contents { get; set; } = [];
26+
27+
public ITaskItem[] ReplacementTokens { get; set; } = [];
2528

2629
[Required]
2730
public string TargetPath { get; set; }
@@ -39,6 +42,8 @@ public class CreatePackage : Task
3942
public ITaskItem OutputPackage { get; set; }
4043

4144
Manifest manifest;
45+
Dictionary<string, string> tokens;
46+
Regex tokensExpr;
4247

4348
public override bool Execute()
4449
{
@@ -68,13 +73,17 @@ public override bool Execute()
6873
}
6974
}
7075

76+
public Manifest Execute(Stream output) => Execute(output, out _);
77+
7178
// Implementation for testing to avoid I/O
72-
public Manifest Execute(Stream output)
79+
public Manifest Execute(Stream output, out Manifest manifest)
7380
{
7481
GeneratePackage(output);
82+
manifest = this.manifest;
7583

7684
output.Seek(0, SeekOrigin.Begin);
7785
using var reader = new PackageArchiveReader(output);
86+
7887
return reader.GetManifest();
7988
}
8089

@@ -92,13 +101,13 @@ public Manifest CreateManifest()
92101
metadata.DevelopmentDependency = true;
93102

94103
if (Manifest.TryGetMetadata("Title", out var title))
95-
metadata.Title = title.TrimIndent();
104+
metadata.Title = ReplaceTokens(title.TrimIndent());
96105

97106
if (Manifest.TryGetMetadata("Description", out var description))
98-
metadata.Description = description.TrimIndent();
107+
metadata.Description = ReplaceTokens(description.TrimIndent());
99108

100109
if (Manifest.TryGetMetadata("Summary", out var summary))
101-
metadata.Summary = summary.TrimIndent();
110+
metadata.Summary = ReplaceTokens(summary.TrimIndent());
102111

103112
if (Manifest.TryGetMetadata("Readme", out var readme))
104113
metadata.Readme = readme;
@@ -107,17 +116,17 @@ public Manifest CreateManifest()
107116
metadata.Language = language;
108117

109118
if (Manifest.TryGetMetadata("Copyright", out var copyright))
110-
metadata.Copyright = copyright;
119+
metadata.Copyright = ReplaceTokens(copyright);
111120

112121
if (Manifest.TryGetBoolMetadata("RequireLicenseAcceptance", out var requireLicenseAcceptance) &&
113122
requireLicenseAcceptance)
114123
metadata.RequireLicenseAcceptance = requireLicenseAcceptance;
115124

116125
if (!string.IsNullOrEmpty(Manifest.GetMetadata("Authors")))
117-
metadata.Authors = Manifest.GetMetadata("Authors").Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
126+
metadata.Authors = Manifest.GetMetadata("Authors").Split([','], StringSplitOptions.RemoveEmptyEntries);
118127

119128
if (!string.IsNullOrEmpty(Manifest.GetMetadata("Owners")))
120-
metadata.Owners = Manifest.GetMetadata("Owners").Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
129+
metadata.Owners = Manifest.GetMetadata("Owners").Split([','], StringSplitOptions.RemoveEmptyEntries);
121130

122131
if (!string.IsNullOrEmpty(Manifest.GetMetadata("LicenseUrl")))
123132
metadata.SetLicenseUrl(Manifest.GetMetadata("LicenseUrl"));
@@ -171,7 +180,7 @@ public Manifest CreateManifest()
171180
metadata.Icon = icon;
172181

173182
if (Manifest.TryGetMetadata("ReleaseNotes", out var releaseNotes))
174-
metadata.ReleaseNotes = releaseNotes.TrimIndent();
183+
metadata.ReleaseNotes = ReplaceTokens(releaseNotes.TrimIndent());
175184

176185
if (Manifest.TryGetMetadata("Tags", out var tags))
177186
metadata.Tags = tags;
@@ -190,6 +199,21 @@ public Manifest CreateManifest()
190199
return manifest;
191200
}
192201

202+
string? ReplaceTokens(string? text)
203+
{
204+
tokens ??= ReplacementTokens
205+
.Select(x => (x.ItemSpec.ToLowerInvariant(), x.GetMetadata("Value")))
206+
.GroupBy(t => t.Item1)
207+
.ToDictionary(g => g.Key, g => g.Last().Item2);
208+
209+
tokensExpr ??= new Regex(@"\$(" + string.Join("|", tokens.Keys.Select(Regex.Escape)) + @")\$", RegexOptions.IgnoreCase);
210+
211+
if (string.IsNullOrEmpty(text) || tokens.Count == 0)
212+
return text;
213+
214+
return tokensExpr.Replace(text, match => tokens[match.Groups[1].Value.ToLower()]);
215+
}
216+
193217
void GeneratePackage(Stream output = null)
194218
{
195219
manifest ??= CreateManifest();
@@ -205,10 +229,27 @@ void GeneratePackage(Stream output = null)
205229
File.Exists(readmeFile.Source))
206230
{
207231
// replace readme with includes replaced.
208-
var replaced = IncludesResolver.Process(readmeFile.Source, message => Log.LogWarningCode("NG001", message));
209-
var temp = Path.GetTempFileName();
210-
File.WriteAllText(temp, replaced);
211-
readmeFile.Source = temp;
232+
var replaced = ReplaceTokens(IncludesResolver.Process(readmeFile.Source, message => Log.LogWarningCode("NG001", message)));
233+
if (!replaced.Equals(File.ReadAllText(readmeFile.Source), StringComparison.Ordinal))
234+
{
235+
var temp = Path.GetTempFileName();
236+
File.WriteAllText(temp, replaced);
237+
readmeFile.Source = temp;
238+
}
239+
}
240+
241+
if (manifest.Metadata.LicenseMetadata?.Type == LicenseType.File &&
242+
manifest.Files.FirstOrDefault(f => Path.GetFileName(f.Target) == manifest.Metadata.LicenseMetadata.License) is ManifestFile licenseFile &&
243+
File.Exists(licenseFile.Source))
244+
{
245+
// replace readme with includes replaced.
246+
var replaced = ReplaceTokens(IncludesResolver.Process(licenseFile.Source, message => Log.LogWarningCode("NG001", message)));
247+
if (!replaced.Equals(File.ReadAllText(licenseFile.Source), StringComparison.Ordinal))
248+
{
249+
var temp = Path.GetTempFileName();
250+
File.WriteAllText(temp, replaced);
251+
licenseFile.Source = temp;
252+
}
212253
}
213254

214255
builder.Files.AddRange(manifest.Files.Select(file =>

src/NuGetizer.Tasks/NuGetizer.Shared.targets

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,23 @@ Copyright (c) .NET Foundation. All rights reserved.
320320
<ItemGroup Condition="'@(NuspecFile)' == ''">
321321
<NuspecFile Include="$(NuspecFile)" />
322322
</ItemGroup>
323+
<ItemGroup>
324+
<PackageReplacementToken Include="Id" Value="$(PackageId)" />
325+
<PackageReplacementToken Include="Version" Value="$(PackageVersion)" />
326+
<PackageReplacementToken Include="Authors" Value="$(Authors)" Condition="'$(Authors)' != ''" />
327+
<PackageReplacementToken Include="Title" Value="$(Title)" Condition="'$(Title)' != ''" />
328+
<PackageReplacementToken Include="Description" Value="$(Description)" Condition="'$(Description)' != ''" />
329+
<PackageReplacementToken Include="Copyright" Value="$(Copyright)" Condition="'$(Copyright)' != ''" />
330+
<PackageReplacementToken Include="Configuration" Value="$(Configuration)" />
331+
<PackageReplacementToken Include="Product" Value="$(Product)" Condition="'$(Product)' != ''" />
332+
</ItemGroup>
323333
<PropertyGroup>
324334
<_NuspecFile>%(NuspecFile.FullPath)</_NuspecFile>
325335
</PropertyGroup>
326336
<CreatePackage Manifest="@(PackageTargetPath)" NuspecFile="$(_NuspecFile)" Contents="@(_PackageContent)"
327337
EmitPackage="$(EmitPackage)" EmitNuspec="$(EmitNuspec)"
328-
TargetPath="@(PackageTargetPath->'%(FullPath)')">
338+
TargetPath="@(PackageTargetPath->'%(FullPath)')"
339+
ReplacementTokens="@(PackageReplacementToken)">
329340
<Output TaskParameter="OutputPackage" ItemName="_PackageTargetPath" />
330341
<Output TaskParameter="OutputPackage" ItemName="FileWrites" Condition="'$(EmitPackage)' == 'true'" />
331342
<Output TaskParameter="NuspecFile" ItemName="FileWrites" Condition="'$(EmitNuspec)' == 'true'" />

src/NuGetizer.Tests/CreatePackageTests.cs

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Diagnostics;
34
using System.IO;
45
using System.IO.Compression;
56
using System.Linq;
67
using Microsoft.Build.Framework;
78
using Microsoft.Build.Utilities;
9+
using Microsoft.VisualStudio.TestPlatform.Utilities;
810
using NuGet.Frameworks;
911
using NuGet.Packaging;
1012
using NuGet.Packaging.Core;
@@ -33,7 +35,7 @@ public CreatePackageTests(ITestOutputHelper output)
3335
{
3436
{ MetadataName.PackageId, "package" },
3537
{ MetadataName.Version, "1.0.0" },
36-
{ "Title", "title" },
38+
{ "Title", "title $id$" },
3739
{ "Description",
3840
"""
3941
@@ -60,7 +62,13 @@ New paragraph preserved.
6062
{ "ReleaseNotes", "release notes" },
6163
{ "MinClientVersion", "3.4.0" },
6264
{ "PackageTypes", PackageType.Dependency.Name }
63-
})
65+
}),
66+
ReplacementTokens =
67+
[
68+
new TaskItem("Id", new Dictionary<string, string> { ["Value"] = "package" }),
69+
new TaskItem("Version", new Dictionary<string, string> { ["Value"] = "1.0.0" }),
70+
new TaskItem("Product", new Dictionary<string, string> { ["Value"] = "NuGetizer" }),
71+
]
6472
};
6573

6674
#if RELEASE
@@ -70,9 +78,16 @@ New paragraph preserved.
7078
#endif
7179
}
7280

73-
Manifest ExecuteTask() => createPackage ?
74-
task.Execute(new MemoryStream()) :
75-
task.CreateManifest();
81+
Manifest ExecuteTask() => ExecuteTask(out _);
82+
83+
Manifest ExecuteTask(out Manifest sourceManifest)
84+
{
85+
if (createPackage)
86+
return task.Execute(new MemoryStream(), out sourceManifest);
87+
88+
sourceManifest = null;
89+
return task.CreateManifest();
90+
}
7691

7792
[Fact]
7893
public void when_output_path_not_exists_then_creates_it()
@@ -118,7 +133,7 @@ public void when_creating_package_then_contains_all_metadata()
118133

119134
Assert.Equal(task.Manifest.GetMetadata("PackageId"), metadata.Id);
120135
Assert.Equal(task.Manifest.GetMetadata("Version"), metadata.Version.ToString());
121-
Assert.Equal(task.Manifest.GetMetadata("Title"), metadata.Title);
136+
Assert.Equal("title package", metadata.Title);
122137
Assert.Equal(task.Manifest.GetMetadata("Summary"), metadata.Summary);
123138
Assert.Equal(task.Manifest.GetMetadata("Language"), metadata.Language);
124139
Assert.Equal(task.Manifest.GetMetadata("Copyright"), metadata.Copyright);
@@ -217,6 +232,74 @@ public void when_creating_package_has_license_file_then_manifest_has_license()
217232
Assert.Null(metadata.LicenseMetadata.WarningsAndErrors);
218233
}
219234

235+
[Fact]
236+
public void when_license_file_has_tokens_then_replacements_applied()
237+
{
238+
var content = Path.GetTempFileName();
239+
File.WriteAllText(content, "EULA for $product$ ($id$).");
240+
task.Contents = new[]
241+
{
242+
new TaskItem(content, new Metadata
243+
{
244+
{ MetadataName.PackageId, task.Manifest.GetMetadata("Id") },
245+
{ MetadataName.PackFolder, PackFolderKind.None },
246+
{ MetadataName.PackagePath, "license.txt" }
247+
}),
248+
};
249+
250+
task.Manifest.SetMetadata("LicenseUrl", "");
251+
task.Manifest.SetMetadata("LicenseFile", "license.txt");
252+
253+
createPackage = true;
254+
ExecuteTask(out var manifest);
255+
256+
Assert.NotNull(manifest);
257+
258+
Assert.Equal("license.txt", manifest.Metadata.LicenseMetadata.License);
259+
Assert.Equal(LicenseType.File, manifest.Metadata.LicenseMetadata.Type);
260+
261+
var file = manifest.Files.FirstOrDefault(f => Path.GetFileName(f.Target) == manifest.Metadata.LicenseMetadata.License);
262+
Assert.NotNull(file);
263+
Assert.True(File.Exists(file.Source));
264+
265+
var eula = File.ReadAllText(file.Source);
266+
267+
Assert.Equal("EULA for NuGetizer (package).", eula);
268+
}
269+
270+
[Fact]
271+
public void when_readme_has_include_and_tokens_then_replacements_applied()
272+
{
273+
var content = Path.GetTempFileName();
274+
File.WriteAllText(content, "<!-- include https://github.com/devlooped/.github/blob/807335297e28cfe5a6dd00ecd72b2ca32c0f1ed8/osmf.md -->");
275+
task.Contents = new[]
276+
{
277+
new TaskItem(content, new Metadata
278+
{
279+
{ MetadataName.PackageId, task.Manifest.GetMetadata("Id") },
280+
{ MetadataName.PackFolder, PackFolderKind.None },
281+
{ MetadataName.PackagePath, "readme.md" }
282+
}),
283+
};
284+
285+
task.Manifest.SetMetadata("Readme", "readme.md");
286+
287+
createPackage = true;
288+
ExecuteTask(out var manifest);
289+
290+
Assert.NotNull(manifest);
291+
292+
Assert.Equal("readme.md", manifest.Metadata.Readme);
293+
294+
var file = manifest.Files.FirstOrDefault(f => Path.GetFileName(f.Target) == manifest.Metadata.Readme);
295+
Assert.NotNull(file);
296+
Assert.True(File.Exists(file.Source));
297+
298+
var readme = File.ReadAllText(file.Source);
299+
300+
Assert.Contains("NuGetizer", readme);
301+
}
302+
220303
[Fact]
221304
public void when_creating_package_with_simple_dependency_then_contains_dependency_group()
222305
{

src/NuGetizer.Tests/given_a_library.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,23 @@ public void when_pack_excludes_additional_items_then_contains_only_matching_file
5151

5252
Assert.Single(compile);
5353
}
54+
55+
[Fact]
56+
public void when_packing_performs_token_replacement()
57+
{
58+
var result = Builder.BuildScenario(nameof(given_a_library),
59+
new { PackCompile = "true", PackOnlyApi = "true" },
60+
target: "Build,GetPackageContents,Pack");
61+
62+
Assert.True(result.BuildResult.HasResultsForTarget("GetPackageContents"));
63+
64+
var items = result.BuildResult.ResultsByTarget["GetPackageContents"];
65+
var compile = items.Items.Where(item => item.Matches(new
66+
{
67+
BuildAction = "Compile",
68+
})).ToArray();
69+
70+
Assert.Single(compile);
71+
}
5472
}
5573
}

0 commit comments

Comments
 (0)