diff --git a/Docs/PowerForge.Web.Pipeline.md b/Docs/PowerForge.Web.Pipeline.md index c55931dc1..cbd1e0ec6 100644 --- a/Docs/PowerForge.Web.Pipeline.md +++ b/Docs/PowerForge.Web.Pipeline.md @@ -1666,6 +1666,17 @@ Notes: - api: `surfaces.apiDotNet` or `surfaces.apiPowerShell` (when `syncApi: true`) - examples: `surfaces.examples` (when `syncExamples: true`) - Optional `projects` / `projectSlugs` limits the sync to specific catalog slugs, which is useful for project-only refresh jobs. +- Optional `linkMappings` adapts Markdown navigation URLs when imported guides live at different routes on the consuming site. Configure mappings per project; each source and target prefix must start and end with `/`. The longest matching prefix wins, and query strings and fragments keep their meaning. Markdown links, navigation targets wrapping images, and used reference definitions are mapped in docs and curated `.md` or `.markdown` examples; code samples, external URLs and other projects remain unchanged. Image-only destinations are preserved; a reference definition shared by a link and an image follows the link mapping. Destinations with encoded prefixes are safely re-escaped after mapping. For example: + + ```json + "linkMappings": { + "pswriteoffice": { + "/docs/pswriteoffice/": "/projects/pswriteoffice/docs/", + "/api/powershell/": "/projects/pswriteoffice/api/" + } + } + ``` + - Supports path candidates: - docs: `sourceDocsPaths` (array) or `sourceDocsPath` (string) - api: `sourceApiPaths` (array) or `sourceApiPath` (string) diff --git a/PowerForge.Tests/MarkdownLinkMapperTests.cs b/PowerForge.Tests/MarkdownLinkMapperTests.cs new file mode 100644 index 000000000..e77cfb776 --- /dev/null +++ b/PowerForge.Tests/MarkdownLinkMapperTests.cs @@ -0,0 +1,96 @@ +using PowerForge.Web; +using OfficeIMO.Markdown; + +namespace PowerForge.Tests; + +public sealed class MarkdownLinkMapperTests +{ + private static readonly IReadOnlyDictionary Mappings = new Dictionary + { + ["/docs/alpha/"] = "/projects/alpha/docs/", + ["/docs/alpha/special/"] = "/guides/", + ["/api/powershell/"] = "/projects/alpha/api/" + }; + + [Fact] + public void Rewrite_MapsNavigationPreservingOriginalMarkdownAndCode() + { + var original = "\uFEFF---\r\ntitle: Guide\r\n---\r\n" + + "[Guide](/docs/alpha/start/?format=word#example \"Title\")\r\n" + + "[API]()\r\n" + + "[Special](/docs/alpha/special/page/)\r\n" + + "![Image](/docs/alpha/image.png) [External](https://example.com/docs/alpha/)\r\n" + + "[Other](/docs/alphabet/page/) [Fragment](#top)\r\n\r\n" + + "`[Inline code](/docs/alpha/code/)`\r\n\r\n" + + "```powershell\r\n[Code](/docs/alpha/code/)\r\n```\r\n"; + var expected = original + .Replace("[Guide](/docs/alpha/", "[Guide](/projects/alpha/docs/", StringComparison.Ordinal) + .Replace("[API]( + { + ["/docs/"] = "/projects/", + ["/projects/"] = "/other/" + }; + Assert.Equal("[Guide](/projects/page/)", MarkdownLinkMapper.Rewrite("[Guide](/docs/page/)", mappings)); + } + + [Fact] + public void Rewrite_RootMappingPreservesProtocolRelativeExternalLinks() + { + const string original = "[External](//example.com/guide/) [Local](/guide/)\n\n[Reference][ext]\n\n[ext]: //example.com/guide/"; + var expected = original.Replace("[Local](/guide/)", "[Local](/projects/alpha/guide/)", StringComparison.Ordinal); + Assert.Equal(expected, MarkdownLinkMapper.Rewrite(original, + new Dictionary { ["/"] = "/projects/alpha/" })); + } + + [Theory] + [InlineData("[![Preview](/images/preview.png)](/docs/alpha/start/)")] + [InlineData("See [![Preview](/images/preview.png)](/docs/alpha/start/) next.")] + public void Rewrite_MapsLinkedImageNavigationAndPreservesImageSource(string markdown) + { + Assert.Equal(markdown.Replace("](/docs/alpha/", "](/projects/alpha/docs/", StringComparison.Ordinal), + MarkdownLinkMapper.Rewrite(markdown, Mappings)); + } + + [Theory] + [InlineData("[Guide](/docs/alpha/start/?x=1&y=2#part)")] + [InlineData("[Guide](/docs/alpha\\/start/?x=1&y=2#part)")] + [InlineData("[Guide][g]\n\n[g]: /docs/alpha/start/?x=1&y=2#part")] + public void Rewrite_MapsDecodedPrefixesPreservingDestinationSemantics(string markdown) + { + var mapped = MarkdownLinkMapper.Rewrite(markdown, Mappings); + var parsed = MarkdownReader.ParseWithSyntaxTree(mapped); + var link = Assert.IsType(Assert.IsType(parsed.Document.Blocks[0]).Inlines.Nodes[0]); + Assert.Equal("/projects/alpha/docs/start/?x=1&y=2#part", link.Url); + } + + [Theory] + [InlineData("https://example.com/", "/docs/")] + [InlineData("//example.com/", "/docs/")] + [InlineData("/docs", "/docs/")] + [InlineData("/docs/", "/target/?x=1/")] + public void Rewrite_RejectsInvalidRoutePrefixesEvenForEmptyMarkdown(string source, string target) + { + Assert.Throws(() => MarkdownLinkMapper.Rewrite(string.Empty, + new Dictionary { [source] = target })); + } +} diff --git a/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs b/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs new file mode 100644 index 000000000..ad080b61f --- /dev/null +++ b/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs @@ -0,0 +1,63 @@ +using PowerForge.Web.Cli; + +namespace PowerForge.Tests; + +public sealed class WebPipelineRunnerProjectDocsLinkMappingTests +{ + [Fact] + public void RunPipeline_MapsOnlyConfiguredProjectDocsAndMarkdownExamples() + { + var root = Path.Combine(Path.GetTempPath(), "pf-web-doc-links-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + File.WriteAllText(Path.Combine(root, "catalog.json"), """ + { "projects": [ + { "slug": "alpha", "surfaces": { "docs": true, "examples": true } }, + { "slug": "beta", "surfaces": { "docs": true, "examples": true } } + ] } + """); + const string markdown = "# Guide\n\n[Guide](/docs/alpha/start/)\n\n```text\n[Code](/docs/alpha/start/)\n```\n"; + foreach (var slug in new[] { "alpha", "beta" }) + { + var source = Path.Combine(root, "sources", slug); + Directory.CreateDirectory(Path.Combine(source, "Docs")); + Directory.CreateDirectory(Path.Combine(source, "content", "examples")); + File.WriteAllText(Path.Combine(source, "Docs", "guide.md"), markdown); + File.WriteAllText(Path.Combine(source, "content", "examples", "sample.md"), markdown); + File.WriteAllText(Path.Combine(source, "content", "examples", "sample.markdown"), markdown); + File.WriteAllText(Path.Combine(source, "content", "examples", "sample.ps1"), markdown); + } + var pipeline = Path.Combine(root, "pipeline.json"); + File.WriteAllText(pipeline, """ + { "steps": [{ + "task": "project-docs-sync", "catalog": "./catalog.json", + "sourcesRoot": "./sources", "contentRoot": "./docs", "examplesRoot": "./examples", + "hydrateFromArtifacts": false, "generateToc": false, + "linkMappings": { "ALPHA": { "/docs/alpha/": "/projects/alpha/docs/" } } + }] } + """); + + var result = WebPipelineRunner.RunPipeline(pipeline, logger: null); + + Assert.True(result.Success, result.Steps.Single().Message); + foreach (var surface in new[] { "docs", "examples" }) + { + var file = surface == "docs" ? "guide.md" : "sample.md"; + var alpha = File.ReadAllText(Path.Combine(root, surface, "alpha", file)); + Assert.Contains("[Guide](/projects/alpha/docs/start/)", alpha, StringComparison.Ordinal); + Assert.Contains("[Code](/docs/alpha/start/)", alpha, StringComparison.Ordinal); + Assert.Contains("[Guide](/docs/alpha/start/)", + File.ReadAllText(Path.Combine(root, surface, "beta", file)), StringComparison.Ordinal); + } + Assert.Equal(markdown, File.ReadAllText(Path.Combine(root, "examples", "alpha", "sample.ps1"))); + Assert.Contains("[Guide](/projects/alpha/docs/start/)", + File.ReadAllText(Path.Combine(root, "examples", "alpha", "sample.markdown")), StringComparison.Ordinal); + Assert.Equal(markdown, File.ReadAllText(Path.Combine(root, "sources", "alpha", "Docs", "guide.md"))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs new file mode 100644 index 000000000..b146c8294 --- /dev/null +++ b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace PowerForge.Web.Cli; + +internal static partial class WebPipelineRunner +{ + private static Dictionary> ReadProjectDocsLinkMappings(JsonElement step) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (!step.TryGetProperty("linkMappings", out var projects)) + return result; + if (projects.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("project-docs-sync linkMappings must be an object keyed by project slug."); + + foreach (var project in projects.EnumerateObject()) + { + if (string.IsNullOrWhiteSpace(project.Name) || project.Value.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("Each project-docs-sync linkMappings entry requires a project slug and an object of URL prefix mappings."); + var prefixes = new Dictionary(StringComparer.Ordinal); + foreach (var prefix in project.Value.EnumerateObject()) + { + if (prefix.Value.ValueKind != JsonValueKind.String || + !prefixes.TryAdd(prefix.Name, prefix.Value.GetString()!)) + throw new InvalidOperationException($"Invalid or duplicate link mapping for project '{project.Name}'."); + } + // Validate even when a configured project is absent from this sync's selection. + MarkdownLinkMapper.Rewrite(string.Empty, prefixes); + if (!result.TryAdd(project.Name.Trim(), prefixes)) + throw new InvalidOperationException($"Duplicate linkMappings project '{project.Name}'."); + } + return result; + } + + private static void CopyProjectDocumentationFile(string sourceFile, string targetFile, string slug, + IReadOnlyDictionary> linkMappings) + { + File.Copy(sourceFile, targetFile, overwrite: true); + if (!IsMarkdownExtension(Path.GetExtension(targetFile)) || + !linkMappings.TryGetValue(slug, out var mappings) || mappings.Count == 0) + return; + var original = File.ReadAllText(targetFile); + var mapped = MarkdownLinkMapper.Rewrite(original, mappings); + if (!string.Equals(original, mapped, StringComparison.Ordinal)) + File.WriteAllText(targetFile, mapped); + } +} diff --git a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.cs b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.cs index eed2adb7f..0611218bb 100644 --- a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.cs +++ b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.cs @@ -80,6 +80,7 @@ private static void ExecuteProjectDocsSync(JsonElement step, string baseDir, Web scalarKeys: new[] { "sourceExamplesPath", "source-examples-path", "sourceExamplesFolder", "source-examples-folder" }, defaults: new[] { "Website/content/examples", "content/examples" }); var selectedProjectSlugs = ResolveProjectDocsProjectFilter(step); + var linkMappings = ReadProjectDocsLinkMappings(step); var apiRoot = ResolvePath(baseDir, GetString(step, "apiRoot") ?? @@ -325,7 +326,7 @@ private static void ExecuteProjectDocsSync(JsonElement step, string baseDir, Web if (!string.IsNullOrWhiteSpace(targetDirectory)) Directory.CreateDirectory(targetDirectory); - File.Copy(sourceFile, targetFile, overwrite: true); + CopyProjectDocumentationFile(sourceFile, targetFile, slug, linkMappings); copiedFiles++; } @@ -447,7 +448,7 @@ private static void ExecuteProjectDocsSync(JsonElement step, string baseDir, Web if (!string.IsNullOrWhiteSpace(targetDirectory)) Directory.CreateDirectory(targetDirectory); - File.Copy(sourceFile, targetFile, overwrite: true); + CopyProjectDocumentationFile(sourceFile, targetFile, slug, linkMappings); copiedExampleFiles++; } diff --git a/PowerForge.Web/Services/MarkdownLinkMapper.cs b/PowerForge.Web/Services/MarkdownLinkMapper.cs new file mode 100644 index 000000000..859c7f379 --- /dev/null +++ b/PowerForge.Web/Services/MarkdownLinkMapper.cs @@ -0,0 +1,110 @@ +using OfficeIMO.Markdown; +using System.Net; +using System.Text; + +namespace PowerForge.Web; + +/// Maps Markdown navigation links when documentation is imported under different site routes. +public static class MarkdownLinkMapper +{ + /// + /// Replaces configured root-relative directory prefixes in Markdown links, including used reference definitions. + /// The longest matching prefix wins. Other source text, line endings, code and unrelated destinations are preserved. + /// + /// The original Markdown source. + /// Source and destination URL prefixes, each starting and ending with a slash. + /// The source with mapped link destinations, or the unchanged source when no links match. + public static string Rewrite(string markdown, IReadOnlyDictionary mappings) + { + ArgumentNullException.ThrowIfNull(markdown); + ArgumentNullException.ThrowIfNull(mappings); + foreach (var mapping in mappings) + { + ValidatePrefix(mapping.Key); + ValidatePrefix(mapping.Value); + } + if (mappings.Count == 0 || markdown.Length == 0) + return markdown; + + var prefixes = mappings.OrderByDescending(static mapping => mapping.Key.Length).ToArray(); + // The reader removes a leading BOM when normalizing; retain it outside the parsed source. + var sourceOffset = markdown[0] == '\uFEFF' ? 1 : 0; + var parsed = MarkdownReader.ParseWithSyntaxTree(markdown[sourceOffset..], new MarkdownReaderOptions + { + PreserveTrivia = true, + DefinitionLists = false + }); + var visitor = new LinkVisitor(parsed, prefixes); + visitor.Visit(parsed.Document); + if (visitor.Replacements.Count == 0) + return markdown; + + var output = new StringBuilder(markdown); + foreach (var replacement in visitor.Replacements.Values.OrderByDescending(static value => value.Start)) + { + output.Remove(replacement.Start + sourceOffset, replacement.Length); + output.Insert(replacement.Start + sourceOffset, replacement.Text); + } + return output.ToString(); + } + + private static void ValidatePrefix(string prefix) + { + if (string.IsNullOrEmpty(prefix) || !prefix.StartsWith('/') || !prefix.EndsWith('/') || + prefix.StartsWith("//", StringComparison.Ordinal) || prefix.Any(static ch => + char.IsWhiteSpace(ch) || ch is '\\' or '?' or '#')) + throw new ArgumentException("Markdown link mappings require root-relative directory prefixes starting and ending with '/', without query or fragment components."); + } + + private sealed class LinkVisitor(MarkdownParseResult parsed, KeyValuePair[] prefixes) : MarkdownVisitor + { + internal Dictionary Replacements { get; } = new(); + + protected override void VisitLinkInline(LinkInline link) + { + MapDestination(link.Url, link.UrlSourceSpan); + base.VisitLinkInline(link); + } + + protected override void VisitImageLinkInline(ImageLinkInline image) + { + MapDestination(image.LinkUrl, image.LinkUrlSourceSpan); + base.VisitImageLinkInline(image); + } + + protected override void VisitImageBlock(ImageBlock image) + { + MapDestination(image.LinkUrl, image.LinkUrlSourceSpan); + base.VisitImageBlock(image); + } + + private void MapDestination(string? url, MarkdownSourceSpan? span) + { + if (string.IsNullOrEmpty(url) || url.StartsWith("//", StringComparison.Ordinal)) + return; + foreach (var prefix in prefixes) + { + if (!url.StartsWith(prefix.Key, StringComparison.Ordinal)) + continue; + if (!span.HasValue || !parsed.TryCreateOriginalSourceSlice(span.Value, out var slice)) + throw new InvalidOperationException($"Cannot safely map Markdown link destination '{url}'."); + + // Keep literal suffixes verbatim. For an encoded prefix, serialize the decoded destination + // through the owning Markdown escaper and HTML encoder so entities are not decoded twice. + var destination = slice.Text.StartsWith(prefix.Key, StringComparison.Ordinal) + ? EscapeDestination(prefix.Value) + slice.Text[prefix.Key.Length..] + : EscapeDestination(prefix.Value + url[prefix.Key.Length..]); + + Replacements[slice.StartOffset] = new Replacement( + slice.StartOffset, slice.EndOffsetInclusive - slice.StartOffset + 1, + destination); + break; + } + } + + private static string EscapeDestination(string destination) => + MarkdownEscaper.EscapeLinkUrl(WebUtility.HtmlEncode(destination).Replace(" ", " ", StringComparison.Ordinal)); + } + + private sealed record Replacement(int Start, int Length, string Text); +} diff --git a/Schemas/powerforge.web.pipelinespec.schema.json b/Schemas/powerforge.web.pipelinespec.schema.json index e66db06a1..d16fb8039 100644 --- a/Schemas/powerforge.web.pipelinespec.schema.json +++ b/Schemas/powerforge.web.pipelinespec.schema.json @@ -1722,6 +1722,14 @@ "additionalProperties": false, "properties": { "task": { "enum": ["project-docs-sync", "sync-project-docs", "project-docs"] }, + "linkMappings": { + "type": "object", + "description": "Markdown navigation URL prefix mappings keyed by project slug. Source and target prefixes must be root-relative directories starting and ending with a slash.", + "additionalProperties": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, "id": { "type": "string" }, "dependsOn": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] }, "mode": { "type": "string" },