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); } }