Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Docs/PowerForge.Web.Pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions PowerForge.Tests/MarkdownLinkMapperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using PowerForge.Web;
using OfficeIMO.Markdown;

namespace PowerForge.Tests;

public sealed class MarkdownLinkMapperTests
{
private static readonly IReadOnlyDictionary<string, string> Mappings = new Dictionary<string, string>
{
["/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](</api/powershell/Get-Thing/>)\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](</api/powershell/", "[API](</projects/alpha/api/", StringComparison.Ordinal)
.Replace("[Special](/docs/alpha/special/", "[Special](/guides/", StringComparison.Ordinal);

Assert.Equal(expected, MarkdownLinkMapper.Rewrite(original, Mappings));
}

[Fact]
public void Rewrite_MapsSharedReferenceDestinationOnceInNestedContent()
{
const string original = "> [First][guide]\n>\n> - **[Second][guide]**\n\n" +
"| Link |\n| --- |\n| [Table][guide] |\n\n" +
"[guide]: /docs/alpha/start/?x=1#part \"Guide\"\n";
var expected = original.Replace("[guide]: /docs/alpha/", "[guide]: /projects/alpha/docs/", StringComparison.Ordinal);

Assert.Equal(expected, MarkdownLinkMapper.Rewrite(original, Mappings));
}

[Fact]
public void Rewrite_DoesNotCascadeMappings()
{
var mappings = new Dictionary<string, string>
{
["/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<string, string> { ["/"] = "/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&#47;start/?x=1&amp;amp;y=2#part)")]
[InlineData("[Guide](/docs/alpha\\/start/?x=1&amp;amp;y=2#part)")]
[InlineData("[Guide][g]\n\n[g]: /docs/alpha&#47;start/?x=1&amp;amp;y=2#part")]
public void Rewrite_MapsDecodedPrefixesPreservingDestinationSemantics(string markdown)
{
var mapped = MarkdownLinkMapper.Rewrite(markdown, Mappings);
var parsed = MarkdownReader.ParseWithSyntaxTree(mapped);
var link = Assert.IsType<LinkInline>(Assert.IsType<ParagraphBlock>(parsed.Document.Blocks[0]).Inlines.Nodes[0]);
Assert.Equal("/projects/alpha/docs/start/?x=1&amp;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<ArgumentException>(() => MarkdownLinkMapper.Rewrite(string.Empty,
new Dictionary<string, string> { [source] = target }));
}
}
63 changes: 63 additions & 0 deletions PowerForge.Tests/WebPipelineRunnerProjectDocsLinkMappingTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<string, IReadOnlyDictionary<string, string>> ReadProjectDocsLinkMappings(JsonElement step)
{
var result = new Dictionary<string, IReadOnlyDictionary<string, string>>(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<string, string>(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<string, IReadOnlyDictionary<string, string>> 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);
}
}
5 changes: 3 additions & 2 deletions PowerForge.Web.Cli/WebPipelineRunner.Tasks.ProjectDocsSync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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") ??
Expand Down Expand Up @@ -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++;
}

Expand Down Expand Up @@ -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++;
}

Expand Down
110 changes: 110 additions & 0 deletions PowerForge.Web/Services/MarkdownLinkMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using OfficeIMO.Markdown;
using System.Net;
using System.Text;

namespace PowerForge.Web;

/// <summary>Maps Markdown navigation links when documentation is imported under different site routes.</summary>
public static class MarkdownLinkMapper
{
/// <summary>
/// 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.
/// </summary>
/// <param name="markdown">The original Markdown source.</param>
/// <param name="mappings">Source and destination URL prefixes, each starting and ending with a slash.</param>
/// <returns>The source with mapped link destinations, or the unchanged source when no links match.</returns>
public static string Rewrite(string markdown, IReadOnlyDictionary<string, string> 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<string, string>[] prefixes) : MarkdownVisitor
{
internal Dictionary<int, Replacement> 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(" ", "&#32;", StringComparison.Ordinal));
}

private sealed record Replacement(int Start, int Length, string Text);
}
8 changes: 8 additions & 0 deletions Schemas/powerforge.web.pipelinespec.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading