diff --git a/AGENTS.md b/AGENTS.md
index 303f6c7..69dde55 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,11 +16,12 @@ Compatible with both SDK Pack and NuGetizer. Does not require NuGetizer.
| Path | Role |
|------|------|
| `src/Readme/IncludesResolver.cs` | Pure include-resolution logic (ported from NuGetizer) |
-| `src/Readme/TokenReplacer.cs` | Pure `$token$` replacement (same algorithm as NuGetizer) |
+| `src/Readme/TokenReplacer.cs` | Pure `$token$` replacement + placeholder discovery |
| `src/Readme/GitHubUrlExpander.cs` | Pure relative-link expansion via Markdig (ported from NuGetizer) |
-| `src/Readme/ProcessReadmeIncludes.cs` | Thin MSBuild task wrapping resolver + tokens + GitHub URLs |
+| `src/Readme/ProcessPackageReadme.cs` | MSBuild task: includes + tokens + GitHub URLs (no unknown-token warn) |
+| `src/Readme/ReplacePackageTokens.cs` | MSBuild task: tokens in arbitrary files (RDM001 on unknown) |
| `src/Readme/build/Readme.props` | Defaults: `PackReadme`, `ReadmeExpandGitHubUrls` |
-| `src/Readme/build/Readme.targets` | Auto-pack item metadata + pre-pack process + NuGetizer `PackageFile` retarget |
+| `src/Readme/build/Readme.targets` | Auto-pack, `CollectReplacementTokens`, `ProcessPackageReadme`, task UsingTasks |
| `src/Readme/buildTransitive/*` | Re-exports `build/` for transitive imports |
| `src/ILRepack.targets` | Merges Markdig into `build/Readme.dll` |
| `src/Tests/` | Unit tests on fixtures + SDK Pack / NuGetizer pack scenario tests |
@@ -32,7 +33,10 @@ Compatible with both SDK Pack and NuGetizer. Does not require NuGetizer.
- **Warnings not errors**: missing includes / anchors call `Log.LogWarning` and leave markers in place.
- **` ```exclude ` fences**: includes inside fenced code blocks with language `exclude` are left literal (for documenting include syntax).
- **Fragment resolution**: explicit `` pairs win (placement controls whether a section title is included); otherwise GitHub heading auto-anchors match and **include the heading line** (e.g. `## Usage` for `#usage`).
-- **Token replacement**: after includes, `$token$` placeholders are replaced via `@(ReadmeReplacementToken)` (official NuGet: Id/Version/Author/Title/Description/Copyright/Configuration; plus Authors and Product; consumer-extensible). Case-insensitive names; last value wins for duplicates. Named separately from NuGetizer's `@(PackageReplacementToken)` to avoid item conflicts.
+- **Token replacement**: after includes, `$token$` placeholders are replaced via `@(PackageReplacementToken)` (official NuGet: Id/Version/Author/Title/Description/Copyright/Configuration; plus Authors and Product; consumer-extensible). Case-insensitive names; **last value wins** for duplicates (never fails). Same item name as NuGetizer for coexistence; newer NuGetizers can adopt this model.
+- **`CollectReplacementTokens`**: populates default `@(PackageReplacementToken)` items. Depend on it before using tokens. Consumers add items anytime; `AfterTargets="CollectReplacementTokens"` to remove/replace defaults; `$(CollectReplacementTokensDependsOn)` / `BeforeTargets` for pre-work.
+- **`ProcessPackageReadme`**: pack-time target (`DependsOnTargets=CollectReplacementTokens;$(ProcessPackageReadmeDependsOn)`) runs the process task then retargets pack items to the intermediate file. Does **not** warn on leftover `$token$` (readme may document that syntax).
+- **`ReplacePackageTokens`**: public task for arbitrary InputFile→OutputFile replacement with `Tokens="@(PackageReplacementToken)"`. Emits **RDM001** for remaining unknown placeholders (suppress via `NoWarn`). Suitable for incremental targets (`Inputs`/`Outputs`).
- **GitHub relative URLs**: after tokens, relative Markdown links/images are rewritten to `https://raw.githubusercontent.com/{owner}/{repo}/{commit}/…` when `ReadmeExpandGitHubUrls` is true (default), `RepositoryUrl` is a github.com absolute URL, and a commit is available (`RepositoryCommit` → `RepositorySha` → `SourceRevisionId`). Auto-skips otherwise. Opt out with `ReadmeExpandGitHubUrls=false`. Uses Markdig `LinkInline` + `NormalizeRenderer` (same as NuGetizer).
- **ILRepack Markdig**: Markdig and its polyfill deps are internalized into `build/Readme.dll` so the package ships a single task assembly (no satellite DLLs under `build/`).
- **Package layout**: development dependency (`DevelopmentDependency=true`); task DLL + props/targets under `build/` (and `buildTransitive/`). Primary output is not under `lib/` (`IncludeBuildOutput=false`).
diff --git a/readme.md b/readme.md
index 62c6837..4a89288 100644
--- a/readme.md
+++ b/readme.md
@@ -74,7 +74,7 @@ Absolute `http(s)` includes from a **local** file are always allowed (subject to
| `ReadmeIncludeScheme` | `https` | Semicolon-separated URI schemes allowed for remote includes (add `http` only if needed) |
| `ReadmeExpandGitHubUrls` | `true` | Expand relative links/images to raw.githubusercontent.com when `RepositoryUrl` is github.com and a commit is available |
| `@(ReadmeIncludeDomain)` | _(empty)_ | Hosts allowed for absolute remote includes nested inside remote content |
-| `@(ReadmeReplacementToken)` | Id, Version, Author, Authors, Title, Description, Copyright, Configuration, Product | `$token$` replacements applied after includes |
+| `@(PackageReplacementToken)` | Id, Version, Author, Authors, Title, Description, Copyright, Configuration, Product | `$token$` replacements (defaults from `CollectReplacementTokens`) |
```xml
@@ -90,15 +90,45 @@ Absolute `http(s)` includes from a **local** file are always allowed (subject to
Supports the [official NuGet replacement tokens](https://learn.microsoft.com/en-us/nuget/reference/nuspec#replacement-tokens) (`$id$`, `$version$`, `$author$`, `$title$`, `$description$`, `$copyright$`, `$configuration$`), plus `$authors$` (MSBuild `$(Authors)`) and `$product$` (as in NuGetizer). Replacements run **after** include expansion so tokens inside included files are replaced too.
-Extensible via MSBuild items `@(ReadmeReplacementToken)` (separate from NuGetizer's `@(PackageReplacementToken)` to avoid conflicts):
+Defaults are populated by the **`CollectReplacementTokens`** target (same item name as NuGetizer: `@(PackageReplacementToken)`). Duplicate names keep the **last** value — never an error — so Readme and NuGetizer can both contribute tokens.
+
+#### Extend tokens (readme and other files)
+
+Add items anytime; use them case-insensitively as `$company$` in the readme:
```xml
-
+
```
-Use the new token case-insensitively in the readme as `$company$`.
+Override or remove defaults **after** they are collected:
+
+```xml
+
+
+
+
+
+
+```
+
+Pre-work before defaults (e.g. compute properties) via `$(CollectReplacementTokensDependsOn)` or `BeforeTargets="CollectReplacementTokens"`.
+
+#### Replace tokens in arbitrary files
+
+`ReplacePackageTokens` applies the same `@(PackageReplacementToken)` map to any file. Pair it with `Inputs` / `Outputs` for incremental builds. Unknown remaining `$token$` placeholders emit warning **RDM001** (suppress with `$(NoWarn);RDM001`). The package-readme path does **not** warn on unknown tokens (docs may contain `$…$` examples).
+
+```xml
+
+
+
+```
### GitHub relative URLs
diff --git a/src/Readme/ProcessReadmeIncludes.cs b/src/Readme/ProcessPackageReadme.cs
similarity index 93%
rename from src/Readme/ProcessReadmeIncludes.cs
rename to src/Readme/ProcessPackageReadme.cs
index d169ab0..8065ca8 100644
--- a/src/Readme/ProcessReadmeIncludes.cs
+++ b/src/Readme/ProcessPackageReadme.cs
@@ -11,8 +11,9 @@ namespace Readme;
/// MSBuild task that resolves <!-- include ... --> directives in a package readme,
/// applies $token$ replacements, optionally expands GitHub relative URLs, and writes the
/// processed content to an output path (typically under BaseIntermediateOutputPath).
+/// Does not warn on unknown $token$ placeholders (readme content may document that syntax).
///
-public class ProcessReadmeIncludes : Task
+public class ProcessPackageReadme : Task
{
/// Source readme file path (project readme before include expansion).
[Required]
@@ -36,7 +37,8 @@ public class ProcessReadmeIncludes : Task
///
/// Token name/value pairs for $token$ replacement after include expansion.
- /// Maps from @(ReadmeReplacementToken) (metadata Value).
+ /// Maps from @(PackageReplacementToken) (metadata Value).
+ /// Duplicate names keep the last value (coexists with NuGetizer contributions).
///
public ITaskItem[]? ReplacementTokens { get; set; }
@@ -114,7 +116,7 @@ public override bool Execute()
Directory.CreateDirectory(directory);
File.WriteAllText(OutputFile, content);
- Log.LogMessage(MessageImportance.Low, "Processed package readme includes: {0} -> {1}", SourceFile, OutputFile);
+ Log.LogMessage(MessageImportance.Low, "Processed package readme: {0} -> {1}", SourceFile, OutputFile);
return !Log.HasLoggedErrors;
}
diff --git a/src/Readme/Readme.csproj b/src/Readme/Readme.csproj
index b528ece..8a6391b 100644
--- a/src/Readme/Readme.csproj
+++ b/src/Readme/Readme.csproj
@@ -8,7 +8,7 @@
$(TargetPath) or build/Readme.dll blocks Compile/Copy (MSB3027). So we:
1. Copy the built DLL to a fixed shadow under obj/Readme.Task/ (Build never writes there).
2. Register UsingTask on that shadow *before* importing build/Readme.targets (first wins).
- 3. Hook Prepare via ProcessPackageReadmeIncludesDependsOn (extension point in shipped targets).
+ 3. Hook Prepare via ProcessPackageReadmeDependsOn (extension point in shipped targets).
4. ContinueOnError on shadow Copy so re-entry after the node already locked the file is OK.
-->
@@ -70,7 +70,7 @@
Pack always runs after Build (dotnet pack / GeneratePackageOnBuild), so outputs exist.
-->
+ DependsOnTargets="CopyTaskAssemblyToBuild;ProcessPackageReadme">
@@ -83,7 +83,7 @@
@@ -108,18 +108,18 @@
Never point at $(TargetPath) or build/Readme.dll: the node locks the loaded file forever.
-->
<_ReadmeTaskAssembly>$(MSBuildProjectDirectory)\obj\Readme.Task\Readme.dll
- PrepareSelfHostReadmeTask;$(ProcessPackageReadmeIncludesDependsOn)
+ PrepareSelfHostReadmeTask;$(ProcessPackageReadmeDependsOn)
-
+
-
+
+/// MSBuild task that replaces NuGet-style $token$ placeholders in an arbitrary file.
+/// Pass Tokens="@(PackageReplacementToken)" after CollectReplacementTokens.
+/// Emits warning RDM001 for any remaining unknown placeholders (suppressible via NoWarn).
+///
+public class ReplacePackageTokens : Task
+{
+ /// Warning code for an unknown $token$ placeholder after replacement.
+ public const string UnknownTokenWarningCode = "RDM001";
+
+ /// Source file path.
+ [Required]
+ public string InputFile { get; set; } = "";
+
+ /// Destination path (parent directories are created). May equal .
+ [Required]
+ public string OutputFile { get; set; } = "";
+
+ ///
+ /// Token name/value pairs (Include = name, metadata Value).
+ /// Maps from @(PackageReplacementToken). Duplicate names keep the last value.
+ ///
+ public ITaskItem[]? Tokens { get; set; }
+
+ public override bool Execute()
+ {
+ if (string.IsNullOrWhiteSpace(InputFile) || !File.Exists(InputFile))
+ {
+ Log.LogError("Input file not found: {0}", InputFile);
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(OutputFile))
+ {
+ Log.LogError("OutputFile is required.");
+ return false;
+ }
+
+ var content = File.ReadAllText(InputFile);
+ var tokens = Tokens?
+ .Select(i => (i.ItemSpec, i.GetMetadata("Value")))
+ ?? Enumerable.Empty<(string, string)>();
+ content = TokenReplacer.Replace(content, tokens);
+
+ foreach (var name in TokenReplacer.FindPlaceholders(content))
+ {
+ Log.LogWarning(
+ subcategory: null,
+ warningCode: UnknownTokenWarningCode,
+ helpKeyword: null,
+ file: InputFile,
+ lineNumber: 0,
+ columnNumber: 0,
+ endLineNumber: 0,
+ endColumnNumber: 0,
+ message: "Unknown package replacement token '${0}$' in '{1}'. Add a PackageReplacementToken item or remove the placeholder.",
+ messageArgs: new object[] { name, InputFile });
+ }
+
+ var directory = Path.GetDirectoryName(OutputFile);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ File.WriteAllText(OutputFile, content);
+ Log.LogMessage(MessageImportance.Low, "Replaced package tokens: {0} -> {1}", InputFile, OutputFile);
+ return !Log.HasLoggedErrors;
+ }
+}
diff --git a/src/Readme/TokenReplacer.cs b/src/Readme/TokenReplacer.cs
index c8b392a..09960b5 100644
--- a/src/Readme/TokenReplacer.cs
+++ b/src/Readme/TokenReplacer.cs
@@ -8,9 +8,17 @@ namespace Readme;
///
/// Replaces NuGet-style $token$ placeholders (case-insensitive token names).
/// Same algorithm as NuGetizer's readme/license token replacement.
+/// Duplicate names keep the last value (never fails) so Readme and NuGetizer can coexist.
///
public static class TokenReplacer
{
+ ///
+ /// Matches $name$ placeholders where name has no whitespace or $.
+ ///
+ static readonly Regex PlaceholderRegex = new(
+ @"\$([^$\s]+)\$",
+ RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
///
/// Replace $name$ tokens in using the given name/value pairs.
/// Duplicate names keep the last value. Token names match case-insensitively.
@@ -35,4 +43,25 @@ public static string Replace(string text, IEnumerable<(string Name, string Value
return expr.Replace(text, match => map[match.Groups[1].Value.ToLowerInvariant()]);
}
+
+ ///
+ /// Returns distinct placeholder names found in (original casing of first occurrence).
+ /// Used after replacement to surface unknown tokens (e.g. task warning RDM001).
+ ///
+ public static IReadOnlyList FindPlaceholders(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return Array.Empty();
+
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var names = new List();
+ foreach (Match match in PlaceholderRegex.Matches(text))
+ {
+ var name = match.Groups[1].Value;
+ if (seen.Add(name))
+ names.Add(name);
+ }
+
+ return names;
+ }
}
diff --git a/src/Readme/build/Readme.targets b/src/Readme/build/Readme.targets
index f07faf6..a34c22c 100644
--- a/src/Readme/build/Readme.targets
+++ b/src/Readme/build/Readme.targets
@@ -3,7 +3,9 @@
@@ -12,7 +14,9 @@
<_ReadmeTaskAssembly Condition="'$(_ReadmeTaskAssembly)' == ''">$(MSBuildThisFileDirectory)Readme.dll
-
+
@@ -82,9 +86,34 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -101,25 +130,6 @@
<_ReadmeProcessedFile>$([System.IO.Path]::Combine('$(BaseIntermediateOutputPath)', '$(_ReadmePackagePath)'))
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
<_ReadmePackageFile Include="@(PackageFile)"
Condition="'%(PackageFile.PackagePath)' == '$(_ReadmePackagePath)' or '%(PackageFile.PackagePath)' == '$(PackageReadmeFile)'" />
diff --git a/src/Tests/GitHubUrlExpanderTests.cs b/src/Tests/GitHubUrlExpanderTests.cs
index 8714793..21417f9 100644
--- a/src/Tests/GitHubUrlExpanderTests.cs
+++ b/src/Tests/GitHubUrlExpanderTests.cs
@@ -116,7 +116,7 @@ public void Expand_StripsGitSuffixEvenWithTrailingSlash()
}
[Fact]
- public void ProcessReadmeIncludesTask_ExpandsGitHubUrlsAfterTokens()
+ public void ProcessPackageReadmeTask_ExpandsGitHubUrlsAfterTokens()
{
var root = Path.Combine(Path.GetTempPath(), "readme-github-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
@@ -126,7 +126,7 @@ public void ProcessReadmeIncludesTask_ExpandsGitHubUrlsAfterTokens()
File.WriteAllText(source, "Package $id$. See [docs](docs/usage.md).\n");
var output = Path.Combine(root, "out", "readme.md");
- var task = new ProcessReadmeIncludes
+ var task = new ProcessPackageReadme
{
SourceFile = source,
OutputFile = output,
@@ -153,7 +153,7 @@ public void ProcessReadmeIncludesTask_ExpandsGitHubUrlsAfterTokens()
}
[Fact]
- public void ProcessReadmeIncludesTask_OptOut_DoesNotExpand()
+ public void ProcessPackageReadmeTask_OptOut_DoesNotExpand()
{
var root = Path.Combine(Path.GetTempPath(), "readme-github-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
@@ -163,7 +163,7 @@ public void ProcessReadmeIncludesTask_OptOut_DoesNotExpand()
File.WriteAllText(source, "See [docs](docs/usage.md).\n");
var output = Path.Combine(root, "out", "readme.md");
- var task = new ProcessReadmeIncludes
+ var task = new ProcessPackageReadme
{
SourceFile = source,
OutputFile = output,
@@ -185,7 +185,7 @@ public void ProcessReadmeIncludesTask_OptOut_DoesNotExpand()
}
[Fact]
- public void ProcessReadmeIncludesTask_MissingCommit_LeavesRelativeUrls()
+ public void ProcessPackageReadmeTask_MissingCommit_LeavesRelativeUrls()
{
var root = Path.Combine(Path.GetTempPath(), "readme-github-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
@@ -195,7 +195,7 @@ public void ProcessReadmeIncludesTask_MissingCommit_LeavesRelativeUrls()
File.WriteAllText(source, "See [docs](docs/usage.md).\n");
var output = Path.Combine(root, "out", "readme.md");
- var task = new ProcessReadmeIncludes
+ var task = new ProcessPackageReadme
{
SourceFile = source,
OutputFile = output,
diff --git a/src/Tests/IncludesResolverTests.cs b/src/Tests/IncludesResolverTests.cs
index 4288ca2..2dd413b 100644
--- a/src/Tests/IncludesResolverTests.cs
+++ b/src/Tests/IncludesResolverTests.cs
@@ -60,12 +60,12 @@ public void ResolveNonExistingInclude_ReportsWarning()
}
[Fact]
- public void ProcessReadmeIncludesTask_WritesProcessedFile()
+ public void ProcessPackageReadmeTask_WritesProcessedFile()
{
var output = Path.Combine(Path.GetTempPath(), "readme-tests", Guid.NewGuid().ToString("N"), "readme.md");
try
{
- var task = new ProcessReadmeIncludes
+ var task = new ProcessPackageReadme
{
SourceFile = ContentPath("readme.md"),
OutputFile = output,
@@ -936,7 +936,7 @@ public void IsSameHostOrSubdomain_RejectsSuffixTraps()
}
[Fact]
- public void ProcessReadmeIncludesTask_AcceptsSchemeAndDomainProperties()
+ public void ProcessPackageReadmeTask_AcceptsSchemeAndDomainProperties()
{
// Verify task property plumbing: schemes/domains flow into Process without error.
using var dir = new TempDir();
@@ -944,7 +944,7 @@ public void ProcessReadmeIncludesTask_AcceptsSchemeAndDomainProperties()
dir.Write("part.md", "PART\n");
var output = Path.Combine(dir.Path, "out.md");
- var task = new ProcessReadmeIncludes
+ var task = new ProcessPackageReadme
{
SourceFile = localOnly,
OutputFile = output,
diff --git a/src/Tests/MockBuildEngine.cs b/src/Tests/MockBuildEngine.cs
index 3f42882..47a7226 100644
--- a/src/Tests/MockBuildEngine.cs
+++ b/src/Tests/MockBuildEngine.cs
@@ -1,4 +1,5 @@
using System.Collections;
+using System.Collections.Generic;
using Microsoft.Build.Framework;
namespace Readme.Tests;
@@ -6,6 +7,10 @@ namespace Readme.Tests;
/// Minimal IBuildEngine for exercising MSBuild tasks in unit tests.
sealed class MockBuildEngine : IBuildEngine
{
+ public List Warnings { get; } = new();
+ public List Errors { get; } = new();
+ public List Messages { get; } = new();
+
public bool ContinueOnError => false;
public int LineNumberOfTaskNode => 0;
public int ColumnNumberOfTaskNode => 0;
@@ -15,7 +20,7 @@ public bool BuildProjectFile(string projectFileName, string[] targetNames, IDict
=> throw new NotImplementedException();
public void LogCustomEvent(CustomBuildEventArgs e) { }
- public void LogErrorEvent(BuildErrorEventArgs e) { }
- public void LogMessageEvent(BuildMessageEventArgs e) { }
- public void LogWarningEvent(BuildWarningEventArgs e) { }
+ public void LogErrorEvent(BuildErrorEventArgs e) => Errors.Add(e);
+ public void LogMessageEvent(BuildMessageEventArgs e) => Messages.Add(e);
+ public void LogWarningEvent(BuildWarningEventArgs e) => Warnings.Add(e);
}
diff --git a/src/Tests/TokenReplacerTests.cs b/src/Tests/TokenReplacerTests.cs
index 93f3ea1..7146d54 100644
--- a/src/Tests/TokenReplacerTests.cs
+++ b/src/Tests/TokenReplacerTests.cs
@@ -63,7 +63,22 @@ public void Replace_DoesNotTouchBareDollarOrPartial()
}
[Fact]
- public void ProcessReadmeIncludesTask_AppliesTokensAfterIncludes()
+ public void FindPlaceholders_ReturnsDistinctNames()
+ {
+ var names = TokenReplacer.FindPlaceholders("A $id$ and $Version$ and $id$ again.");
+ Assert.Equal(new[] { "id", "Version" }, names);
+ }
+
+ [Fact]
+ public void FindPlaceholders_EmptyOrNone_ReturnsEmpty()
+ {
+ Assert.Empty(TokenReplacer.FindPlaceholders(""));
+ Assert.Empty(TokenReplacer.FindPlaceholders("no tokens here"));
+ Assert.Empty(TokenReplacer.FindPlaceholders(null!));
+ }
+
+ [Fact]
+ public void ProcessPackageReadmeTask_AppliesTokensAfterIncludes()
{
var root = Path.Combine(Path.GetTempPath(), "readme-token-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
@@ -74,7 +89,7 @@ public void ProcessReadmeIncludesTask_AppliesTokensAfterIncludes()
File.WriteAllText(Path.Combine(root, "part.md"), "Included $product$.\n");
var output = Path.Combine(root, "out", "readme.md");
- var task = new ProcessReadmeIncludes
+ var task = new ProcessPackageReadme
{
SourceFile = source,
OutputFile = output,
@@ -103,11 +118,46 @@ public void ProcessReadmeIncludesTask_AppliesTokensAfterIncludes()
}
}
+ [Fact]
+ public void ProcessPackageReadme_DoesNotWarnOnUnknownToken()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "readme-token-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ try
+ {
+ var source = Path.Combine(root, "readme.md");
+ File.WriteAllText(source, "Document `$docs-example$` and package $id$.\n");
+ var output = Path.Combine(root, "out", "readme.md");
+ var engine = new MockBuildEngine();
+
+ var task = new ProcessPackageReadme
+ {
+ SourceFile = source,
+ OutputFile = output,
+ BuildEngine = engine,
+ ReplacementTokens =
+ [
+ new Microsoft.Build.Utilities.TaskItem("Id", new Dictionary { ["Value"] = "Pkg" }),
+ ],
+ };
+
+ Assert.True(task.Execute());
+ var content = File.ReadAllText(output);
+ Assert.Contains("package Pkg.", content);
+ Assert.Contains("$docs-example$", content);
+ Assert.DoesNotContain(engine.Warnings, w => w.Code == ReplacePackageTokens.UnknownTokenWarningCode);
+ }
+ finally
+ {
+ try { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+
[Fact]
public void NeutralizeIncludeOpeners_BreaksIncludeRegexButKeepsReadableText()
{
var input = "before\n\n\nafter\n";
- var result = ProcessReadmeIncludes.NeutralizeIncludeOpeners(input);
+ var result = ProcessPackageReadme.NeutralizeIncludeOpeners(input);
Assert.DoesNotMatch(@"", result);
@@ -117,7 +167,77 @@ public void NeutralizeIncludeOpeners_BreaksIncludeRegexButKeepsReadableText()
}
[Fact]
- public void BuildTargets_WireReadmeReplacementTokens()
+ public void ReplacePackageTokensTask_ReplacesAndWrites()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "readme-token-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ try
+ {
+ var input = Path.Combine(root, "eula.txt");
+ File.WriteAllText(input, "License for $id$ version $version$.\n");
+ var output = Path.Combine(root, "out", "EULA.txt");
+ var engine = new MockBuildEngine();
+
+ var task = new ReplacePackageTokens
+ {
+ InputFile = input,
+ OutputFile = output,
+ BuildEngine = engine,
+ Tokens =
+ [
+ new Microsoft.Build.Utilities.TaskItem("Id", new Dictionary { ["Value"] = "My.Lib" }),
+ new Microsoft.Build.Utilities.TaskItem("Version", new Dictionary { ["Value"] = "2.0.0" }),
+ ],
+ };
+
+ Assert.True(task.Execute());
+ Assert.Equal("License for My.Lib version 2.0.0.\n", File.ReadAllText(output));
+ Assert.Empty(engine.Warnings);
+ }
+ finally
+ {
+ try { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+
+ [Fact]
+ public void ReplacePackageTokensTask_WarnsOnUnknownToken()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "readme-token-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ try
+ {
+ var input = Path.Combine(root, "eula.txt");
+ File.WriteAllText(input, "Package $id$ and $unknown$ and $unknown$.\n");
+ var output = Path.Combine(root, "out", "EULA.txt");
+ var engine = new MockBuildEngine();
+
+ var task = new ReplacePackageTokens
+ {
+ InputFile = input,
+ OutputFile = output,
+ BuildEngine = engine,
+ Tokens =
+ [
+ new Microsoft.Build.Utilities.TaskItem("Id", new Dictionary { ["Value"] = "Pkg" }),
+ ],
+ };
+
+ Assert.True(task.Execute());
+ Assert.Equal("Package Pkg and $unknown$ and $unknown$.\n", File.ReadAllText(output));
+ var unknown = Assert.Single(engine.Warnings);
+ Assert.Equal(ReplacePackageTokens.UnknownTokenWarningCode, unknown.Code);
+ Assert.Contains("$unknown$", unknown.Message);
+ Assert.Contains(input, unknown.Message);
+ }
+ finally
+ {
+ try { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+
+ [Fact]
+ public void BuildTargets_WirePackageReplacementTokens()
{
var targetsPath = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", "..", "src", "Readme", "build", "Readme.targets"));
@@ -145,11 +265,16 @@ public void BuildTargets_WireReadmeReplacementTokens()
Assert.True(File.Exists(targetsPath), $"Readme.targets not found: {targetsPath}");
var targets = File.ReadAllText(targetsPath);
- Assert.Contains("ReadmeReplacementToken Include=\"Id\"", targets);
- Assert.Contains("ReadmeReplacementToken Include=\"Version\"", targets);
- Assert.Contains("ReadmeReplacementToken Include=\"Author\"", targets);
- Assert.Contains("ReadmeReplacementToken Include=\"Authors\"", targets);
- Assert.Contains("ReadmeReplacementToken Include=\"Product\"", targets);
- Assert.Contains("ReplacementTokens=\"@(ReadmeReplacementToken)\"", targets);
+ Assert.Contains("Target Name=\"CollectReplacementTokens\"", targets);
+ Assert.Contains("Target Name=\"ProcessPackageReadme\"", targets);
+ Assert.Contains("DependsOnTargets=\"CollectReplacementTokens;$(ProcessPackageReadmeDependsOn)\"", targets);
+ Assert.Contains("UsingTask TaskName=\"Readme.ProcessPackageReadme\"", targets);
+ Assert.Contains("UsingTask TaskName=\"Readme.ReplacePackageTokens\"", targets);
+ Assert.Contains("PackageReplacementToken Include=\"Id\"", targets);
+ Assert.Contains("PackageReplacementToken Include=\"Version\"", targets);
+ Assert.Contains("PackageReplacementToken Include=\"Author\"", targets);
+ Assert.Contains("PackageReplacementToken Include=\"Authors\"", targets);
+ Assert.Contains("PackageReplacementToken Include=\"Product\"", targets);
+ Assert.Contains("ReplacementTokens=\"@(PackageReplacementToken)\"", targets);
}
}