From 97ca2ef1cad89e38e944869a1b84ceeb1056dcef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 6 Oct 2026 21:14:55 +0200 Subject: [PATCH 1/2] Map imported Markdown navigation to consumer routes --- Docs/PowerForge.Web.Pipeline.md | 11 +++ PowerForge.Tests/MarkdownLinkMapperTests.cs | 65 +++++++++++++++ ...pelineRunnerProjectDocsLinkMappingTests.cs | 60 ++++++++++++++ ...elineRunner.Tasks.ProjectDocsSync.Links.cs | 49 +++++++++++ ...WebPipelineRunner.Tasks.ProjectDocsSync.cs | 5 +- PowerForge.Web/Services/MarkdownLinkMapper.cs | 83 +++++++++++++++++++ .../powerforge.web.pipelinespec.schema.json | 8 ++ 7 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 PowerForge.Tests/MarkdownLinkMapperTests.cs create mode 100644 PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs create mode 100644 PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs create mode 100644 PowerForge.Web/Services/MarkdownLinkMapper.cs diff --git a/Docs/PowerForge.Web.Pipeline.md b/Docs/PowerForge.Web.Pipeline.md index c55931dc1..ffe2cff58 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 stay intact. Inline links and their used reference definitions are mapped in docs and curated 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. 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..dbf5a0c66 --- /dev/null +++ b/PowerForge.Tests/MarkdownLinkMapperTests.cs @@ -0,0 +1,65 @@ +using PowerForge.Web; + +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)); + } + + [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..ecdd5119f --- /dev/null +++ b/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs @@ -0,0 +1,60 @@ +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.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.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..6fab1273e --- /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 (!Path.GetExtension(targetFile).Equals(".md", StringComparison.OrdinalIgnoreCase) || + !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..1040242d0 --- /dev/null +++ b/PowerForge.Web/Services/MarkdownLinkMapper.cs @@ -0,0 +1,83 @@ +using OfficeIMO.Markdown; +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) + { + foreach (var prefix in prefixes) + { + if (!link.Url.StartsWith(prefix.Key, StringComparison.Ordinal)) + continue; + if (!link.UrlSourceSpan.HasValue || + !parsed.TryCreateOriginalSourceSlice(link.UrlSourceSpan.Value, out var slice) || + !slice.Text.StartsWith(prefix.Key, StringComparison.Ordinal)) + throw new InvalidOperationException($"Cannot safely map Markdown link destination '{link.Url}'."); + + Replacements[slice.StartOffset] = new Replacement( + slice.StartOffset, slice.EndOffsetInclusive - slice.StartOffset + 1, + prefix.Value + slice.Text[prefix.Key.Length..]); + break; + } + base.VisitLinkInline(link); + } + } + + 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" }, From 126b7ace7b4498062afbe68f0aae099c4b86eb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 6 Oct 2026 21:25:16 +0200 Subject: [PATCH 2/2] Preserve external and encoded Markdown destinations --- Docs/PowerForge.Web.Pipeline.md | 2 +- PowerForge.Tests/MarkdownLinkMapperTests.cs | 31 ++++++++++++++ ...pelineRunnerProjectDocsLinkMappingTests.cs | 3 ++ ...elineRunner.Tasks.ProjectDocsSync.Links.cs | 2 +- PowerForge.Web/Services/MarkdownLinkMapper.cs | 41 +++++++++++++++---- 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/Docs/PowerForge.Web.Pipeline.md b/Docs/PowerForge.Web.Pipeline.md index ffe2cff58..cbd1e0ec6 100644 --- a/Docs/PowerForge.Web.Pipeline.md +++ b/Docs/PowerForge.Web.Pipeline.md @@ -1666,7 +1666,7 @@ 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 stay intact. Inline links and their used reference definitions are mapped in docs and curated 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. For example: +- 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": { diff --git a/PowerForge.Tests/MarkdownLinkMapperTests.cs b/PowerForge.Tests/MarkdownLinkMapperTests.cs index dbf5a0c66..e77cfb776 100644 --- a/PowerForge.Tests/MarkdownLinkMapperTests.cs +++ b/PowerForge.Tests/MarkdownLinkMapperTests.cs @@ -1,4 +1,5 @@ using PowerForge.Web; +using OfficeIMO.Markdown; namespace PowerForge.Tests; @@ -52,6 +53,36 @@ public void Rewrite_DoesNotCascadeMappings() 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/")] diff --git a/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs b/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs index ecdd5119f..ad080b61f 100644 --- a/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs +++ b/PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs @@ -25,6 +25,7 @@ public void RunPipeline_MapsOnlyConfiguredProjectDocsAndMarkdownExamples() 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"); @@ -50,6 +51,8 @@ public void RunPipeline_MapsOnlyConfiguredProjectDocsAndMarkdownExamples() 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 diff --git a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs index 6fab1273e..b146c8294 100644 --- a/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs +++ b/PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.Links.cs @@ -38,7 +38,7 @@ private static void CopyProjectDocumentationFile(string sourceFile, string targe IReadOnlyDictionary> linkMappings) { File.Copy(sourceFile, targetFile, overwrite: true); - if (!Path.GetExtension(targetFile).Equals(".md", StringComparison.OrdinalIgnoreCase) || + if (!IsMarkdownExtension(Path.GetExtension(targetFile)) || !linkMappings.TryGetValue(slug, out var mappings) || mappings.Count == 0) return; var original = File.ReadAllText(targetFile); diff --git a/PowerForge.Web/Services/MarkdownLinkMapper.cs b/PowerForge.Web/Services/MarkdownLinkMapper.cs index 1040242d0..859c7f379 100644 --- a/PowerForge.Web/Services/MarkdownLinkMapper.cs +++ b/PowerForge.Web/Services/MarkdownLinkMapper.cs @@ -1,4 +1,5 @@ using OfficeIMO.Markdown; +using System.Net; using System.Text; namespace PowerForge.Web; @@ -61,22 +62,48 @@ private sealed class LinkVisitor(MarkdownParseResult parsed, KeyValuePair + MarkdownEscaper.EscapeLinkUrl(WebUtility.HtmlEncode(destination).Replace(" ", " ", StringComparison.Ordinal)); } private sealed record Replacement(int Start, int Length, string Text);