From 95aa10fdd2a2a63a4d022c64ee843e571a3aba55 Mon Sep 17 00:00:00 2001 From: XtremeOwnage <5262735+XtremeOwnageDotCom@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:10:19 -0500 Subject: [PATCH 1/2] fix(imported-docs): load config and keep imported page structure The importedDocs section was never read by the config loader, so pushedDocsDir, pullSources and s3Sources were always empty and the plugin could not be enabled from appsettings.json at all. Imported pages also bypassed the conventions discovered pages follow: ComputeUrl flattened every source subdirectory onto destinationPath via Path.GetFileName, emitted a leading slash that no other page has, and never collapsed index.md onto its directory. Front matter went through a hand-rolled line splitter that dropped list values and ignored nav_title/page_title/tag_title, and S3 pages were created without an OutputPath. Imported pages now route through ContentDiscovery.UrlFor and FrontMatter.Split, so nesting, index collapsing, slugify.urls and full YAML front matter behave the same as for local docs. --- .../appsettings.imported-docs.example.json | 24 ++-- docs-site/docs/plugins/imported-docs.md | 52 ++++--- .../Configuration/JsonConfigLoader.cs | 41 ++++++ src/Netdocs.Plugins/ImportedDocsPlugin.cs | 128 ++++++------------ tests/Netdocs.Core.Tests/ConfigTests.cs | 98 ++++++++++++++ .../ImportedDocsPluginTests.cs | 105 ++++++++++++++ 6 files changed, 333 insertions(+), 115 deletions(-) diff --git a/.github/workflows/examples/appsettings.imported-docs.example.json b/.github/workflows/examples/appsettings.imported-docs.example.json index a9aab88..6688bfb 100644 --- a/.github/workflows/examples/appsettings.imported-docs.example.json +++ b/.github/workflows/examples/appsettings.imported-docs.example.json @@ -2,7 +2,7 @@ // with push-based, pull-based (git), and S3-based documentation sources { - "siteConfig": { + "Netdocs": { // ... other site config ... "importedDocs": { @@ -61,15 +61,15 @@ } } ] - } - }, - - // Plugin configuration - "plugins": [ - // ... other plugins ... - { - "name": "imported-docs" - // No options needed; configure via siteConfig.importedDocs - } - ] + }, + + // Plugin configuration + "plugins": [ + // ... other plugins ... + { + "name": "imported-docs" + // No options needed; configure via the importedDocs section above + } + ] + } } diff --git a/docs-site/docs/plugins/imported-docs.md b/docs-site/docs/plugins/imported-docs.md index aaca43e..2f52462 100644 --- a/docs-site/docs/plugins/imported-docs.md +++ b/docs-site/docs/plugins/imported-docs.md @@ -12,14 +12,15 @@ It implements `IImportHook.OnImportAsync`, which runs after content discovery an ### Minimal Configuration -Enable the plugin in your `appsettings.json`: +Enable the plugin in your `appsettings.json`, and configure it under `importedDocs` in the +same `Netdocs` section: ```json { - "plugins": [ - { "name": "imported-docs" } - ], - "siteConfig": { + "Netdocs": { + "plugins": [ + { "name": "imported-docs" } + ], "importedDocs": { "pushedDocsDir": "imported" } @@ -29,6 +30,9 @@ Enable the plugin in your `appsettings.json`: This enables push-based imports. External repos can push documentation to the `/imported` directory. +`importedDocs` is read by the JSON config loader, so a site still running from `mkdocs.yml` +needs to move to `appsettings.json` to use this plugin. + ## Import hook behavior `imported-docs` is the built-in plugin that implements `IImportHook.OnImportAsync`. @@ -44,7 +48,7 @@ This enables push-based imports. External repos can push documentation to the `/ ```json { - "siteConfig": { + "Netdocs": { "importedDocs": { "pushedDocsDir": "imported", "pullSources": [ @@ -228,10 +232,10 @@ jobs: ```json { - "plugins": [ - { "name": "imported-docs" } - ], - "siteConfig": { + "Netdocs": { + "plugins": [ + { "name": "imported-docs" } + ], "importedDocs": { "pushedDocsDir": "imported", "pullSources": [ @@ -267,10 +271,10 @@ jobs: ```json { - "plugins": [ - { "name": "imported-docs" } - ], - "siteConfig": { + "Netdocs": { + "plugins": [ + { "name": "imported-docs" } + ], "importedDocs": { "s3Sources": [ { @@ -556,15 +560,23 @@ This metadata is available in your templates for displaying "View on GitHub" or ## URL Mapping -Imported files are mapped to URLs following Netdocs conventions: +Imported files are mapped to URLs with the same rules discovered pages use, so an imported +tree keeps its shape and its relative cross-links keep resolving. -- File: `docs/guide.md` with `destinationPath: "products/api"` -- URL: `/products/api/guide/` +| Source file | `destinationPath` | URL | +|---|---|---| +| `guide.md` | `products/api` | `/products/api/guide/` | +| `integrations/citrix.md` | `products/api` | `/products/api/integrations/citrix/` | +| `index.md` | `products/api` | `/products/api/` | +| `integrations/index.md` | `products/api` | `/products/api/integrations/` | +| `guide.md` | _(omitted)_ | `/guide/` | Behavior: -- `.md` extension is removed -- Trailing slash always added -- Destination is applied at directory level + +- The `.md` extension is removed and a trailing slash is added. +- Directories below the source path are preserved beneath `destinationPath`. +- `index.md` and `README.md` collapse onto their containing directory. +- When the site sets `slugify.urls`, imported segments are slugified too. ## Build Pipeline Integration diff --git a/src/Netdocs.Core/Configuration/JsonConfigLoader.cs b/src/Netdocs.Core/Configuration/JsonConfigLoader.cs index de07950..74216f1 100644 --- a/src/Netdocs.Core/Configuration/JsonConfigLoader.cs +++ b/src/Netdocs.Core/Configuration/JsonConfigLoader.cs @@ -45,9 +45,50 @@ public static SiteConfig Load(string appSettingsPath) Deploy = ParseDeploy(root.Get("deploy").AsMap()), Optimize = ParseOptimize(root.Get("optimize").AsMap()), Validation = ParseValidation(root.Get("validation").AsMap()), + ImportedDocs = ParseImportedDocs(root.Get("importedDocs").AsMap()), }; } + private static ImportedDocsConfig ParseImportedDocs(IReadOnlyDictionary m) => new() + { + PushedDocsDir = m.Get("pushedDocsDir").AsString(), + PullSources = [.. m.Get("pullSources").AsList().Select(x => ParsePullSource(x.AsMap()))], + S3Sources = [.. m.Get("s3Sources").AsList().Select(x => ParseS3Source(x.AsMap()))], + }; + + private static ImportedDocsPullSource ParsePullSource(IReadOnlyDictionary m) => new() + { + Repository = Required(m, "repository", "importedDocs.pullSources"), + Reference = m.Get("reference").AsString(), + SourcePath = m.Get("sourcePath").AsString() ?? "docs", + DestinationPath = m.Get("destinationPath").AsString(), + AuthTokenEnvVar = m.Get("authTokenEnvVar").AsString(), + ScheduleCron = m.Get("scheduleCron").AsString(), + IncludeSourceMarker = m.Get("includeSourceMarker").AsBool(false), + Exclude = StringList(m.Get("exclude")), + FrontMatterDefaults = m.Get("frontMatterDefaults").AsMap(), + }; + + private static ImportedDocsS3Source ParseS3Source(IReadOnlyDictionary m) => new() + { + Bucket = Required(m, "bucket", "importedDocs.s3Sources"), + Prefix = Required(m, "prefix", "importedDocs.s3Sources"), + Region = Required(m, "region", "importedDocs.s3Sources"), + DestinationPath = m.Get("destinationPath").AsString(), + CredentialsEnvVar = m.Get("credentialsEnvVar").AsString(), + IncludeSourceMarker = m.Get("includeSourceMarker").AsBool(false), + Exclude = StringList(m.Get("exclude")), + FrontMatterDefaults = m.Get("frontMatterDefaults").AsMap(), + }; + + private static string Required(IReadOnlyDictionary m, string key, string section) + { + var value = m.Get(key).AsString(); + return string.IsNullOrWhiteSpace(value) + ? throw new InvalidOperationException($"Each entry in '{section}' requires a non-empty '{key}'.") + : value; + } + private static ValidationConfig ParseValidation(IReadOnlyDictionary m) => new() { Links = m.Get("links").AsBool(false), diff --git a/src/Netdocs.Plugins/ImportedDocsPlugin.cs b/src/Netdocs.Plugins/ImportedDocsPlugin.cs index 6570abe..37cb2cb 100644 --- a/src/Netdocs.Plugins/ImportedDocsPlugin.cs +++ b/src/Netdocs.Plugins/ImportedDocsPlugin.cs @@ -4,6 +4,7 @@ using LibGit2Sharp; using Microsoft.Extensions.Logging; using Netdocs.Abstractions; +using Netdocs.Core.Content; namespace Netdocs.Plugins; @@ -18,6 +19,7 @@ public sealed class ImportedDocsPlugin : IPlugin, IImportHook private string? _pushedDocsDir; private IReadOnlyList _pullSources = []; private IReadOnlyList _s3Sources = []; + private SlugifyConfig? _slugify; private ILogger _logger = null!; public string Name => "imported-docs"; @@ -26,6 +28,7 @@ public void Configure(IPluginContext ctx) { _projectRoot = ctx.Config.ProjectRoot; _logger = ctx.Logger; + _slugify = ctx.Config.SlugifyUrls ? ctx.Config.Slugify : null; var config = ctx.Config.ImportedDocs; _pushedDocsDir = config.PushedDocsDir; @@ -247,18 +250,16 @@ private bool GlobMatch(string path, string pattern) SourcePath = filePath, RelativePath = relPath, Url = url, - OutputPath = Path.Combine(site.Config.AbsoluteSiteDir, url.TrimStart('/'), "index.html"), + OutputPath = Path.Combine(site.Config.AbsoluteSiteDir, ContentDiscovery.OutputFileFor(url)), RawMarkdown = content, IsGenerated = false }; - // Extract front-matter and apply overrides - ExtractFrontMatterAndApplyOverrides(page, source); + ApplyFrontMatter(page, source?.FrontMatterDefaults); if (source?.IncludeSourceMarker == true) { - page.Meta["import_source"] = source.Repository; - page.Meta["import_url"] = source.Repository; + AddSourceMarker(page, source.Repository); } return page; @@ -270,77 +271,59 @@ private bool GlobMatch(string path, string pattern) } } - private void ExtractFrontMatterAndApplyOverrides(Page page, ImportedDocsPullSource? source) + /// Parses the page's own front matter with the same reader used for discovered + /// pages, then fills any key the source did not set from the configured defaults. + private static void ApplyFrontMatter(Page page, IReadOnlyDictionary? defaults) { - var lines = page.RawMarkdown.Split('\n'); - var meta = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // Simple YAML front-matter parsing (---...---) - if (lines.Length > 0 && lines[0].Trim() == "---") - { - var endIdx = Array.FindIndex(lines, 1, l => l.Trim() == "---"); - if (endIdx > 1) - { - for (int i = 1; i < endIdx; i++) - { - var line = lines[i]; - var colonIdx = line.IndexOf(':'); - if (colonIdx > 0) - { - var key = line[..colonIdx].Trim(); - var value = line[(colonIdx + 1)..].Trim(); - meta[key] = ParseYamlValue(value); - } - } + var (parsed, body) = FrontMatter.Split(page.RawMarkdown); + page.RawMarkdown = body; - // Remove front-matter from raw markdown - page.RawMarkdown = string.Join("\n", lines[(endIdx + 1)..]); - } - } + var meta = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in parsed) + meta[key] = value; - // Apply front-matter defaults from the import source config - if (source?.FrontMatterDefaults is not null) + if (defaults is not null) { - foreach (var (key, value) in source.FrontMatterDefaults) - { + foreach (var (key, value) in defaults) meta.TryAdd(key, value); - } } page.FrontMatter = meta; - // Populate title if present in front-matter - if (meta.TryGetValue("title", out var titleObj) && titleObj is string title) - { + if (meta.TryGetValue("title", out var t) && t is string title && title.Length > 0) page.Title = title; - } else if (string.IsNullOrEmpty(page.Title)) - { - page.Title = Path.GetFileNameWithoutExtension(page.SourcePath); - } + page.Title = Path.GetFileNameWithoutExtension(page.RelativePath); + + if (meta.TryGetValue("page_title", out var pt) && pt is string pageTitle && pageTitle.Length > 0) + page.PageTitle = pageTitle; + if (meta.TryGetValue("nav_title", out var nt) && nt is string navTitle && navTitle.Length > 0) + page.NavTitle = navTitle; + if (meta.TryGetValue("tag_title", out var gt) && gt is string tagTitle && tagTitle.Length > 0) + page.TagTitle = tagTitle; } - private object? ParseYamlValue(string value) + private static void AddSourceMarker(Page page, string source) { - value = value.Trim('"', '\''); - if (value.Equals("true", StringComparison.OrdinalIgnoreCase)) return true; - if (value.Equals("false", StringComparison.OrdinalIgnoreCase)) return false; - if (int.TryParse(value, out var intVal)) return intVal; - return value; + var meta = new Dictionary(page.FrontMatter, StringComparer.OrdinalIgnoreCase); + meta.TryAdd("import_source", source); + meta.TryAdd("import_url", page.Url); + page.FrontMatter = meta; } - private string ComputeUrl(string relativePath, string? destinationPath) + /// Maps a source-relative path onto a site URL, nesting the source's own directory + /// structure beneath so cross-links inside the imported + /// set keep resolving. + internal string ComputeUrl(string relativePath, string? destinationPath) { - // Remove .md extension, use forward slashes, add trailing slash - var path = relativePath[..^3]; // Remove ".md" - path = path.Replace('\\', '/'); + var path = relativePath.Replace('\\', '/').TrimStart('/'); if (!string.IsNullOrEmpty(destinationPath)) { - path = destinationPath.TrimEnd('/') + "/" + Path.GetFileName(path); + path = destinationPath.Replace('\\', '/').Trim('/') + "/" + path; } - return "/" + path.Trim('/') + "/"; + return ContentDiscovery.UrlFor(path, _slugify); } private async Task ImportS3SourceAsync(SiteContext site, ImportedDocsS3Source source, CancellationToken ct) @@ -384,7 +367,7 @@ private async Task ImportS3SourceAsync(SiteContext site, ImportedDocsS3Sour continue; } - var page = await LoadPageFromS3Async(s3Client, source, obj.Key, relPath, ct); + var page = await LoadPageFromS3Async(s3Client, source, site, obj.Key, relPath, ct); if (page is not null) { site.Pages.Add(page); @@ -435,6 +418,7 @@ private IAmazonS3 CreateS3Client(ImportedDocsS3Source source) private async Task LoadPageFromS3Async( IAmazonS3 s3Client, ImportedDocsS3Source source, + SiteContext site, string s3Key, string relPath, CancellationToken ct) @@ -447,44 +431,22 @@ private IAmazonS3 CreateS3Client(ImportedDocsS3Source source) using var reader = new StreamReader(response.ResponseStream); var content = await reader.ReadToEndAsync(ct); + var url = ComputeUrl(relPath, source.DestinationPath); + var page = new Page { SourcePath = $"s3://{source.Bucket}/{s3Key}", RelativePath = relPath, - Url = ComputeUrl(relPath, source.DestinationPath), - Title = Path.GetFileNameWithoutExtension(relPath), + Url = url, + OutputPath = Path.Combine(site.Config.AbsoluteSiteDir, ContentDiscovery.OutputFileFor(url)), RawMarkdown = content, }; - // Extract front-matter and apply overrides - ExtractFrontMatterAndApplyOverrides(page, null); - - // Add S3-specific front-matter defaults - var meta = new Dictionary(page.FrontMatter, StringComparer.OrdinalIgnoreCase); + ApplyFrontMatter(page, source.FrontMatterDefaults); - // Apply front-matter defaults from source config - if (source.FrontMatterDefaults is not null) - { - foreach (var (key, value) in source.FrontMatterDefaults) - { - meta.TryAdd(key, value); - } - } - - // Add source marker if requested if (source.IncludeSourceMarker) { - var s3Url = $"https://{source.Bucket}.s3.amazonaws.com/{s3Key}"; - meta.TryAdd("import_source", s3Url); - meta.TryAdd("import_url", ComputeUrl(relPath, source.DestinationPath)); - } - - page.FrontMatter = meta; - - // Update title from front-matter if present - if (meta.TryGetValue("title", out var titleObj) && titleObj is string title) - { - page.Title = title; + AddSourceMarker(page, $"https://{source.Bucket}.s3.amazonaws.com/{s3Key}"); } return page; diff --git a/tests/Netdocs.Core.Tests/ConfigTests.cs b/tests/Netdocs.Core.Tests/ConfigTests.cs index 4d6cf0d..573e8f4 100644 --- a/tests/Netdocs.Core.Tests/ConfigTests.cs +++ b/tests/Netdocs.Core.Tests/ConfigTests.cs @@ -5,6 +5,104 @@ namespace Netdocs.Core.Tests; public class ConfigTests { + [Fact] + public void JsonConfigLoader_ParsesImportedDocsSources() + { + var json = """ + { + "Netdocs": { + "siteName": "Test Site", + "importedDocs": { + "pushedDocsDir": "imported", + "pullSources": [ + { + "repository": "https://github.com/owner/repo.git", + "reference": "v2.0", + "sourcePath": "documentation", + "destinationPath": "products/cli", + "authTokenEnvVar": "DOCS_PAT", + "includeSourceMarker": true, + "exclude": ["draft/**"], + "frontMatterDefaults": { "nav_title": "CLI" } + } + ], + "s3Sources": [ + { + "bucket": "shared-docs", + "prefix": "api-docs/", + "region": "us-east-1", + "destinationPath": "products/api" + } + ] + } + } + } + """; + var path = Path.Combine(Path.GetTempPath(), $"appsettings_{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + try + { + var config = JsonConfigLoader.Load(path); + + Assert.Equal("imported", config.ImportedDocs.PushedDocsDir); + + var pull = Assert.Single(config.ImportedDocs.PullSources); + Assert.Equal("https://github.com/owner/repo.git", pull.Repository); + Assert.Equal("v2.0", pull.Reference); + Assert.Equal("documentation", pull.SourcePath); + Assert.Equal("products/cli", pull.DestinationPath); + Assert.Equal("DOCS_PAT", pull.AuthTokenEnvVar); + Assert.True(pull.IncludeSourceMarker); + Assert.Equal(["draft/**"], pull.Exclude); + Assert.Equal("CLI", pull.FrontMatterDefaults["nav_title"]); + + var s3 = Assert.Single(config.ImportedDocs.S3Sources); + Assert.Equal("shared-docs", s3.Bucket); + Assert.Equal("api-docs/", s3.Prefix); + Assert.Equal("us-east-1", s3.Region); + Assert.Equal("products/api", s3.DestinationPath); + } + finally { File.Delete(path); } + } + + [Fact] + public void JsonConfigLoader_DefaultsImportedDocsToEmpty() + { + var path = Path.Combine(Path.GetTempPath(), $"appsettings_{Guid.NewGuid():N}.json"); + File.WriteAllText(path, """{ "Netdocs": { "siteName": "Test Site" } }"""); + try + { + var config = JsonConfigLoader.Load(path); + + Assert.Null(config.ImportedDocs.PushedDocsDir); + Assert.Empty(config.ImportedDocs.PullSources); + Assert.Empty(config.ImportedDocs.S3Sources); + } + finally { File.Delete(path); } + } + + [Fact] + public void JsonConfigLoader_RejectsS3SourceMissingRequiredKey() + { + var json = """ + { + "Netdocs": { + "importedDocs": { + "s3Sources": [ { "bucket": "shared-docs", "region": "us-east-1" } ] + } + } + } + """; + var path = Path.Combine(Path.GetTempPath(), $"appsettings_{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + try + { + var ex = Assert.Throws(() => JsonConfigLoader.Load(path)); + Assert.Contains("prefix", ex.Message); + } + finally { File.Delete(path); } + } + [Fact] public void YamlTree_ResolvesEnvTagWithDefault() { diff --git a/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs b/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs index c8eef29..4606bbc 100644 --- a/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs +++ b/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Netdocs.Abstractions; using Netdocs.Plugins; @@ -10,6 +12,109 @@ namespace Netdocs.Core.Tests; public class ImportedDocsPluginTests { + private sealed class FakeContext : IPluginContext + { + public SiteConfig Config { get; init; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = new Dictionary(); + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } + + private static ImportedDocsPlugin Configured(SiteConfig? config = null) + { + var plugin = new ImportedDocsPlugin(); + plugin.Configure(new FakeContext { Config = config ?? new SiteConfig() }); + return plugin; + } + + [Theory] + [InlineData("guide.md", "products/api", "products/api/guide/")] + [InlineData("integrations/citrix.md", "products/api", "products/api/integrations/citrix/")] + [InlineData("a/b/c/deep.md", "products/api", "products/api/a/b/c/deep/")] + [InlineData("guide.md", null, "guide/")] + [InlineData("nested/guide.md", null, "nested/guide/")] + public void ComputeUrl_PreservesSourceDirectoriesBeneathDestination( + string relativePath, string? destinationPath, string expected) + { + Assert.Equal(expected, Configured().ComputeUrl(relativePath, destinationPath)); + } + + [Theory] + [InlineData("index.md", "products/api", "products/api/")] + [InlineData("integrations/index.md", "products/api", "products/api/integrations/")] + [InlineData("README.md", "products/api", "products/api/")] + public void ComputeUrl_CollapsesIndexOntoItsDirectory( + string relativePath, string destinationPath, string expected) + { + Assert.Equal(expected, Configured().ComputeUrl(relativePath, destinationPath)); + } + + [Fact] + public void ComputeUrl_SlugifiesSegmentsWhenSiteSlugifiesUrls() + { + var plugin = Configured(new SiteConfig { SlugifyUrls = true }); + + Assert.Equal("products/api/getting-started/", plugin.ComputeUrl("Getting Started.md", "products/api")); + } + + [Fact] + public async Task OnImportAsync_PushedDocs_ParsesFullFrontMatterAndSetsOutputPath() + { + var projectRoot = Path.Combine(Path.GetTempPath(), "netdocs-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(projectRoot, "imported", "guides")); + await File.WriteAllTextAsync( + Path.Combine(projectRoot, "imported", "guides", "setup.md"), + """ + --- + title: Setup + nav_title: Set It Up + tags: + - Alpha + - Beta + --- + # Setup + """); + + var config = new SiteConfig + { + ProjectRoot = projectRoot, + ImportedDocs = new ImportedDocsConfig { PushedDocsDir = "imported" }, + }; + var site = new SiteContext + { + Config = config, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + var plugin = new ImportedDocsPlugin(); + plugin.Configure(new FakeContext { Config = config }); + + try + { + await plugin.OnImportAsync(site, default); + + var page = Assert.Single(site.Pages); + Assert.Equal("imported/guides/setup/", page.Url); + Assert.Equal( + Path.Combine(config.AbsoluteSiteDir, "imported", "guides", "setup", "index.html"), + page.OutputPath); + Assert.Equal("Setup", page.Title); + Assert.Equal("Set It Up", page.NavTitle); + Assert.Equal(["Alpha", "Beta"], Assert.IsAssignableFrom>(page.FrontMatter["tags"])); + Assert.StartsWith("# Setup", page.RawMarkdown.TrimStart()); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + private static SiteContext CreateTestSiteContext(string? projectRoot = null) { projectRoot ??= Path.Combine(Path.GetTempPath(), "netdocs-test-" + Guid.NewGuid().ToString("N")); From 4c963605aa80aeac5ee95fde8f25c63217d798cb Mon Sep 17 00:00:00 2001 From: XtremeOwnage <5262735+XtremeOwnageDotCom@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:40:59 -0500 Subject: [PATCH 2/2] fix(imported-docs): place imported pages under destinationPath in the nav RelativePath drives the navigation tree, the .pages lookup and the internal link map, but imported pages kept their source-relative path while only Url was rewritten. A source subdirectory therefore surfaced as its own top-level nav section instead of nesting under destinationPath, and an imported index.md was dropped from the nav entirely. RelativePath is now derived from the same combined path as Url, so a .pages file in the corresponding directory of the host docs tree titles and orders the imported section. --- docs-site/docs/plugins/imported-docs.md | 5 ++ src/Netdocs.Plugins/ImportedDocsPlugin.cs | 31 ++++---- .../ImportedDocsPluginTests.cs | 71 +++++++++++++++++++ 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/docs-site/docs/plugins/imported-docs.md b/docs-site/docs/plugins/imported-docs.md index 2f52462..2881800 100644 --- a/docs-site/docs/plugins/imported-docs.md +++ b/docs-site/docs/plugins/imported-docs.md @@ -578,6 +578,11 @@ Behavior: - `index.md` and `README.md` collapse onto their containing directory. - When the site sets `slugify.urls`, imported segments are slugified too. +Imported pages are also placed in the navigation tree at `destinationPath`, so they nest +under the surrounding sections rather than at the site root. A `.pages` file in the matching +directory of your own `docs/` tree — `docs/products/api/.pages` for the examples above — +titles and orders the imported section, even though none of its pages live there. + ## Build Pipeline Integration The Imported Docs plugin runs at **Stage 2** of the build pipeline — after initial content discovery but before navigation filters and rendering. This ensures: diff --git a/src/Netdocs.Plugins/ImportedDocsPlugin.cs b/src/Netdocs.Plugins/ImportedDocsPlugin.cs index 37cb2cb..4a9bb2f 100644 --- a/src/Netdocs.Plugins/ImportedDocsPlugin.cs +++ b/src/Netdocs.Plugins/ImportedDocsPlugin.cs @@ -242,8 +242,10 @@ private bool GlobMatch(string path, string pattern) if (string.IsNullOrWhiteSpace(content)) return null; - var relPath = Path.GetRelativePath(baseDir, filePath).Replace('\\', '/'); - var url = ComputeUrl(relPath, source?.DestinationPath); + var relPath = CombineDestination( + Path.GetRelativePath(baseDir, filePath).Replace('\\', '/'), + source?.DestinationPath); + var url = ContentDiscovery.UrlFor(relPath, _slugify); var page = new Page { @@ -311,21 +313,21 @@ private static void AddSourceMarker(Page page, string source) page.FrontMatter = meta; } - /// Maps a source-relative path onto a site URL, nesting the source's own directory - /// structure beneath so cross-links inside the imported - /// set keep resolving. - internal string ComputeUrl(string relativePath, string? destinationPath) + /// Nests a source-relative path beneath . Imported + /// pages carry this as their so navigation, .pages + /// lookups and URLs all agree on where the page lives. + private static string CombineDestination(string relativePath, string? destinationPath) { var path = relativePath.Replace('\\', '/').TrimStart('/'); - if (!string.IsNullOrEmpty(destinationPath)) - { - path = destinationPath.Replace('\\', '/').Trim('/') + "/" + path; - } - - return ContentDiscovery.UrlFor(path, _slugify); + return string.IsNullOrEmpty(destinationPath) + ? path + : destinationPath.Replace('\\', '/').Trim('/') + "/" + path; } + internal string ComputeUrl(string relativePath, string? destinationPath) => + ContentDiscovery.UrlFor(CombineDestination(relativePath, destinationPath), _slugify); + private async Task ImportS3SourceAsync(SiteContext site, ImportedDocsS3Source source, CancellationToken ct) { _logger.LogInformation("Importing docs from S3: s3://{Bucket}/{Prefix}", source.Bucket, source.Prefix); @@ -431,12 +433,13 @@ private IAmazonS3 CreateS3Client(ImportedDocsS3Source source) using var reader = new StreamReader(response.ResponseStream); var content = await reader.ReadToEndAsync(ct); - var url = ComputeUrl(relPath, source.DestinationPath); + var sitePath = CombineDestination(relPath, source.DestinationPath); + var url = ContentDiscovery.UrlFor(sitePath, _slugify); var page = new Page { SourcePath = $"s3://{source.Bucket}/{s3Key}", - RelativePath = relPath, + RelativePath = sitePath, Url = url, OutputPath = Path.Combine(site.Config.AbsoluteSiteDir, ContentDiscovery.OutputFileFor(url)), RawMarkdown = content, diff --git a/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs b/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs index 4606bbc..b9e3606 100644 --- a/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs +++ b/tests/Netdocs.Core.Tests/ImportedDocsPluginTests.cs @@ -62,6 +62,77 @@ public void ComputeUrl_SlugifiesSegmentsWhenSiteSlugifiesUrls() Assert.Equal("products/api/getting-started/", plugin.ComputeUrl("Getting Started.md", "products/api")); } + [Fact] + public async Task OnImportAsync_PulledDocs_PlacesPagesUnderDestinationForNavigation() + { + var origin = Path.Combine(Path.GetTempPath(), "netdocs-origin-" + Guid.NewGuid().ToString("N")); + var projectRoot = Path.Combine(Path.GetTempPath(), "netdocs-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(origin, "docs", "integrations")); + Directory.CreateDirectory(projectRoot); + await File.WriteAllTextAsync(Path.Combine(origin, "docs", "index.md"), "# Landing"); + await File.WriteAllTextAsync(Path.Combine(origin, "docs", "integrations", "citrix.md"), "# Citrix"); + + LibGit2Sharp.Repository.Init(origin); + using (var repo = new LibGit2Sharp.Repository(origin)) + { + LibGit2Sharp.Commands.Stage(repo, "*"); + var who = new LibGit2Sharp.Signature("t", "t@t", DateTimeOffset.UtcNow); + repo.Commit("init", who, who, new LibGit2Sharp.CommitOptions()); + } + + var config = new SiteConfig + { + ProjectRoot = projectRoot, + ImportedDocs = new ImportedDocsConfig + { + PullSources = + [ + new ImportedDocsPullSource + { + Repository = origin, + SourcePath = "docs", + DestinationPath = "aws/iam", + }, + ], + }, + }; + var site = new SiteContext + { + Config = config, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + var plugin = new ImportedDocsPlugin(); + plugin.Configure(new FakeContext { Config = config }); + + try + { + await plugin.OnImportAsync(site, default); + + // RelativePath drives the nav tree and .pages lookup, so it has to agree with Url. + Assert.Equal( + ["aws/iam/index.md", "aws/iam/integrations/citrix.md"], + site.Pages.Select(p => p.RelativePath).Order()); + Assert.Equal( + ["aws/iam/", "aws/iam/integrations/citrix/"], + site.Pages.Select(p => p.Url).Order()); + } + finally + { + DeleteTree(origin); + DeleteTree(projectRoot); + } + } + + private static void DeleteTree(string path) + { + if (!Directory.Exists(path)) return; + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(path, recursive: true); + } + [Fact] public async Task OnImportAsync_PushedDocs_ParsesFullFrontMatterAndSetsOutputPath() {