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..2881800 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,28 @@ 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. + +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 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..4a9bb2f 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; @@ -239,26 +242,26 @@ 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 { 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,79 +273,61 @@ 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); + var (parsed, body) = FrontMatter.Split(page.RawMarkdown); + page.RawMarkdown = body; - // 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); - } - } - - // 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) + /// 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) { - // 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); - } - - return "/" + path.Trim('/') + "/"; + 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); @@ -384,7 +369,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 +420,7 @@ private IAmazonS3 CreateS3Client(ImportedDocsS3Source source) private async Task LoadPageFromS3Async( IAmazonS3 s3Client, ImportedDocsS3Source source, + SiteContext site, string s3Key, string relPath, CancellationToken ct) @@ -447,44 +433,23 @@ private IAmazonS3 CreateS3Client(ImportedDocsS3Source source) using var reader = new StreamReader(response.ResponseStream); var content = await reader.ReadToEndAsync(ct); + var sitePath = CombineDestination(relPath, source.DestinationPath); + var url = ContentDiscovery.UrlFor(sitePath, _slugify); + var page = new Page { SourcePath = $"s3://{source.Bucket}/{s3Key}", - RelativePath = relPath, - Url = ComputeUrl(relPath, source.DestinationPath), - Title = Path.GetFileNameWithoutExtension(relPath), + RelativePath = sitePath, + 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..b9e3606 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,180 @@ 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_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() + { + 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"));