diff --git a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs index dc7ada51f..f61176fd4 100644 --- a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs +++ b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs @@ -28,7 +28,7 @@ public static void EmitError(this InlineProcessor processor, int line, int colum } - public static void EmitWarning(this BlockProcessor processor, int line, int column, int length, string message) + public static void EmitWarning(this InlineProcessor processor, int line, int column, int length, string message) { var context = processor.GetContext(); if (context.SkipValidation) return; diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj index 50d6e2af7..b6674eb43 100644 --- a/src/Elastic.Markdown/Elastic.Markdown.csproj +++ b/src/Elastic.Markdown/Elastic.Markdown.csproj @@ -26,8 +26,4 @@ - - - - diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 201c91416..1662c664c 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -19,7 +19,7 @@ public class DocumentationSet public ConfigurationFile Configuration { get; } - private MarkdownParser MarkdownParser { get; } + public MarkdownParser MarkdownParser { get; } public DocumentationSet(BuildContext context) : this(null, null, context) { } @@ -27,11 +27,22 @@ public DocumentationSet(IDirectoryInfo? sourcePath, IDirectoryInfo? outputPath, { SourcePath = sourcePath ?? context.ReadFileSystem.DirectoryInfo.New(Path.Combine(Paths.Root.FullName, "docs/source")); OutputPath = outputPath ?? context.WriteFileSystem.DirectoryInfo.New(Path.Combine(Paths.Root.FullName, ".artifacts/docs/html")); + + var configurationFile = SourcePath.EnumerateFiles("docset.yml", SearchOption.AllDirectories).FirstOrDefault(); + if (configurationFile is null) + { + configurationFile = context.ReadFileSystem.FileInfo.New(Path.Combine(SourcePath.FullName, "docset.yml")); + context.EmitWarning(configurationFile, "No configuration file found"); + } + + if (configurationFile.Directory!.FullName != SourcePath.FullName) + SourcePath = configurationFile.Directory; + + MarkdownParser = new MarkdownParser(SourcePath, context, GetTitle); + Name = SourcePath.FullName; - MarkdownParser = new MarkdownParser(SourcePath, context); OutputStateFile = OutputPath.FileSystem.FileInfo.New(Path.Combine(OutputPath.FullName, ".doc.state")); - var configurationFile = context.ReadFileSystem.FileInfo.New(Path.Combine(SourcePath.FullName, "docset.yml")); Configuration = new ConfigurationFile(configurationFile, SourcePath, context); Files = context.ReadFileSystem.Directory @@ -57,6 +68,18 @@ public DocumentationSet(IDirectoryInfo? sourcePath, IDirectoryInfo? outputPath, Tree = new DocumentationFolder(Configuration.TableOfContents, FlatMappedFiles, folderFiles); } + private string? GetTitle(string relativePath) => GetMarkdownFile(relativePath)?.YamlFrontMatter?.Title; + + public MarkdownFile? GetMarkdownFile(string relativePath) + { + if (FlatMappedFiles.TryGetValue(relativePath, out var file) && file is MarkdownFile markdownFile) + return markdownFile; + return null; + } + + public async Task ResolveDirectoryTree(Cancel ctx) => + await Tree.Resolve(ctx); + private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context) { if (Configuration.Exclude.Any(g => g.IsMatch(file.Name))) diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index f75525c89..2e2e9031c 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -208,7 +208,7 @@ private void WriteIncludeBlock(HtmlRenderer renderer, IncludeBlock block) if (!block.Found || block.IncludePath is null) return; - var parser = new MarkdownParser(block.DocumentationSourcePath, block.Build); + var parser = new MarkdownParser(block.DocumentationSourcePath, block.Build, block.GetTitle); var file = block.FileSystem.FileInfo.New(block.IncludePath); var document = parser.ParseAsync(file, block.FrontMatter, default).GetAwaiter().GetResult(); var html = document.ToHtml(parser.Pipeline); diff --git a/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs b/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs index 5052eb910..5a387ed87 100644 --- a/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs @@ -13,6 +13,8 @@ public class IncludeBlock(DirectiveBlockParser parser, Dictionary? GetTitle { get; } = context.GetTitle; + public IFileSystem FileSystem { get; } = context.Build.ReadFileSystem; public IDirectoryInfo DocumentationSourcePath { get; } = context.Parser.SourcePath; diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs new file mode 100644 index 000000000..4f129183b --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -0,0 +1,87 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst.Directives; +using Markdig; +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Parsers.Inlines; +using Markdig.Renderers; +using Markdig.Syntax.Inlines; + +namespace Elastic.Markdown.Myst.InlineParsers; + +public static class DirectiveMarkdownBuilderExtensions +{ + public static MarkdownPipelineBuilder UseDiagnosticLinks(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} + +public class DiagnosticLinkInlineExtensions : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) => + pipeline.InlineParsers.Replace(new DiagnosticLinkInlineParser()); + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { } +} + +public class DiagnosticLinkInlineParser : LinkInlineParser +{ + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + var match = base.Match(processor, ref slice); + if (!match) return false; + + if (processor.Inline is not LinkInline link) + return match; + + var url = link.Url; + var line = link.Line + 1; + var column = link.Column; + var length = url?.Length ?? 1; + + var context = processor.GetContext(); + if (processor.GetContext().SkipValidation) + return match; + + if (string.IsNullOrEmpty(url)) + { + processor.EmitWarning(line, column, length, $"Found empty url"); + return match; + } + + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme.StartsWith("http")) + { + processor.EmitWarning(line, column, length, $"external URI: {uri} "); + return match; + } + + var includeFrom = context.Path.Directory!.FullName; + if (url.StartsWith('/')) + includeFrom = context.Parser.SourcePath.FullName; + + var pathOnDisk = Path.Combine(includeFrom, url.TrimStart('/')); + if (!context.Build.ReadFileSystem.File.Exists(pathOnDisk)) + processor.EmitError(line, column, length, $"`{url}` does not exist. resolved to `{pathOnDisk}"); + + if (link.FirstChild == null) + { + var title = context.GetTitle?.Invoke(url); + if (!string.IsNullOrEmpty(title)) + link.AppendChild(new LiteralInline(title)); + } + + if (url.EndsWith(".md")) + link.Url = Path.ChangeExtension(url, ".html"); + + return match; + + + + } +} diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 0176f2b69..b57a371e7 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -6,6 +6,7 @@ using Cysharp.IO; using Elastic.Markdown.Myst.Comments; using Elastic.Markdown.Myst.Directives; +using Elastic.Markdown.Myst.InlineParsers; using Elastic.Markdown.Myst.Substitution; using Markdig; using Markdig.Extensions.EmphasisExtras; @@ -13,21 +14,23 @@ namespace Elastic.Markdown.Myst; -public class MarkdownParser(IDirectoryInfo sourcePath, BuildContext context) +public class MarkdownParser(IDirectoryInfo sourcePath, BuildContext context, Func? getTitle) { public IDirectoryInfo SourcePath { get; } = sourcePath; public BuildContext Context { get; } = context; public MarkdownPipeline MinimalPipeline { get; } = new MarkdownPipelineBuilder() + .UseDiagnosticLinks() .UseSubstitution() .UseYamlFrontMatter() .Build(); - public MarkdownPipeline Pipeline { get; } = + public MarkdownPipeline Pipeline => new MarkdownPipelineBuilder() .EnableTrackTrivia() .UsePreciseSourceLocation() + .UseDiagnosticLinks() .UseGenericAttributes() .UseEmphasisExtras(EmphasisExtraOptions.Default) .UseSoftlineBreakAsHardlineBreak() @@ -43,13 +46,20 @@ public class MarkdownParser(IDirectoryInfo sourcePath, BuildContext context) public Task MinimalParseAsync(IFileInfo path, Cancel ctx) { - var context = new ParserContext(this, path, null, Context) { SkipValidation = true }; + var context = new ParserContext(this, path, null, Context) + { + SkipValidation = true, + GetTitle = getTitle + }; return ParseAsync(path, context, MinimalPipeline, ctx); } public Task ParseAsync(IFileInfo path, YamlFrontMatter? matter, Cancel ctx) { - var context = new ParserContext(this, path, matter, Context); + var context = new ParserContext(this, path, matter, Context) + { + GetTitle = getTitle + }; return ParseAsync(path, context, Pipeline, ctx); } diff --git a/src/Elastic.Markdown/Myst/ParserContext.cs b/src/Elastic.Markdown/Myst/ParserContext.cs index d693e8185..0b071193b 100644 --- a/src/Elastic.Markdown/Myst/ParserContext.cs +++ b/src/Elastic.Markdown/Myst/ParserContext.cs @@ -52,4 +52,5 @@ public ParserContext(MarkdownParser markdownParser, public YamlFrontMatter? FrontMatter { get; } public BuildContext Build { get; } public bool SkipValidation { get; init; } + public Func? GetTitle { get; init; } } diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 308d5bae2..db32ff52c 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -51,6 +51,8 @@ public abstract class DirectiveTest : IAsyncLifetime protected MarkdownDocument Document { get; private set; } protected MockFileSystem FileSystem { get; } protected TestDiagnosticsCollector Collector { get; } + protected DocumentationSet Set { get; set; } + protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown")]string content) { @@ -62,9 +64,13 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") { CurrentDirectory = Paths.Root.FullName }); + // ReSharper disable once VirtualMemberCallInConstructor + // nasty but sub implementations won't use class state. + AddToFileSystem(FileSystem); + + var root = FileSystem.DirectoryInfo.New(Path.Combine(Paths.Root.FullName, "docs/source")); + FileSystem.GenerateDocSetYaml(root); - var file = FileSystem.FileInfo.New("docs/source/index.md"); - var root = file.Directory!; Collector = new TestDiagnosticsCollector(logger); var context = new BuildContext { @@ -72,13 +78,14 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") WriteFileSystem = FileSystem, Collector = Collector }; - var parser = new MarkdownParser(root, context); - - File = new MarkdownFile(file, root, parser, context); + Set = new DocumentationSet(null, null, context); + File = Set.GetMarkdownFile("index.md") ?? throw new NullReferenceException(); Html = default!; //assigned later Document = default!; } + protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } + public virtual async Task InitializeAsync() { var collectTask = Task.Run(async () => await Collector.StartAsync(default), default); diff --git a/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs b/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs index 2d4ca2f55..d2a862fad 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ImageTests.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.IO.Abstractions.TestingHelpers; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Myst.Directives; using FluentAssertions; @@ -18,11 +19,8 @@ public class ImageBlockTests(ITestOutputHelper output) : DirectiveTest + fileSystem.AddFile(@"docs/source/img/observability.png", ""); [Fact] public void ParsesBlock() => Block.Should().NotBeNull(); diff --git a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs index c77cfeb3f..2384cc0c5 100644 --- a/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/FileInclusion/IncludeTests.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.IO.Abstractions.TestingHelpers; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Tests.Directives; @@ -18,12 +19,11 @@ public class IncludeTests(ITestOutputHelper output) : DirectiveTest(output, content) +{ + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // language=markdown + var inclusion = +""" +--- +title: Special Requirements +--- + +To follow this tutorial you will need to install the following components: +"""; + fileSystem.AddFile(@"docs/source/elastic/search-labs/search/req.md", inclusion); + fileSystem.AddFile(@"docs/source/_static/img/observability.png", new MockFileData("")); + } + +} + +public class InlineLinkTests(ITestOutputHelper output) : LinkTestBase(output, +""" +[Elasticsearch](/_static/img/observability.png) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Elasticsearch

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class LinkToPageTests(ITestOutputHelper output) : LinkTestBase(output, +""" +[Requirements](elastic/search-labs/search/req.md) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Requirements

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class InsertPageTitleTests(ITestOutputHelper output) : LinkTestBase(output, +""" +[](elastic/search-labs/search/req.md) +""" +) +{ + [Fact] + public void GeneratesHtml() => + // language=html + Html.Should().Contain( + """

Special Requirements

""" + ); + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index ae68328e2..83cbc0137 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -2,9 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information using System.IO.Abstractions.TestingHelpers; -using Elastic.Markdown.Diagnostics; using Elastic.Markdown.IO; -using Elastic.Markdown.Myst; +using Elastic.Markdown.Tests.Directives; using FluentAssertions; using JetBrains.Annotations; using Markdig.Syntax; @@ -65,37 +64,56 @@ public abstract class InlineTest : IAsyncLifetime protected MarkdownFile File { get; } protected string Html { get; private set; } protected MarkdownDocument Document { get; private set; } + protected TestDiagnosticsCollector Collector { get; } + protected MockFileSystem FileSystem { get; } + protected DocumentationSet Set { get; } + protected InlineTest(ITestOutputHelper output, [LanguageInjection("markdown")]string content) { var logger = new TestLoggerFactory(output); - var fileSystem = new MockFileSystem(new Dictionary + FileSystem = new MockFileSystem(new Dictionary { { "docs/source/index.md", new MockFileData(content) } }, new MockFileSystemOptions { CurrentDirectory = Paths.Root.FullName }); + // ReSharper disable once VirtualMemberCallInConstructor + // nasty but sub implementations won't use class state. + AddToFileSystem(FileSystem); + + var root = FileSystem.DirectoryInfo.New(Path.Combine(Paths.Root.FullName, "docs/source")); + FileSystem.GenerateDocSetYaml(root); - var file = fileSystem.FileInfo.New("docs/source/index.md"); - var root = fileSystem.DirectoryInfo.New(Paths.Root.FullName); + Collector = new TestDiagnosticsCollector(logger); var context = new BuildContext { - ReadFileSystem = fileSystem, - WriteFileSystem = fileSystem, - Collector = new DiagnosticsCollector(logger, []) + ReadFileSystem = FileSystem, + WriteFileSystem = FileSystem, + Collector = Collector }; - var parser = new MarkdownParser(root, context); - - File = new MarkdownFile(file, root, parser, context); + Set = new DocumentationSet(null, null, context); + File = Set.GetMarkdownFile("index.md") ?? throw new NullReferenceException(); Html = default!; //assigned later Document = default!; } + protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } + public virtual async Task InitializeAsync() { + var collectTask = Task.Run(async () => await Collector.StartAsync(default), default); + + await Set.ResolveDirectoryTree(default); + Document = await File.ParseFullAsync(default); Html = File.CreateHtml(Document); + Collector.Channel.TryComplete(); + + await collectTask; + await Collector.Channel.Reader.Completion; + await Collector.StopAsync(default); } public Task DisposeAsync() => Task.CompletedTask; diff --git a/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs new file mode 100644 index 000000000..2705e1896 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/MockFileSystemExtensions.cs @@ -0,0 +1,27 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; + +namespace Elastic.Markdown.Tests; + +public static class MockFileSystemExtensions +{ + public static void GenerateDocSetYaml(this MockFileSystem fileSystem, IDirectoryInfo root) + { + // language=yaml + var yaml = new StringWriter(); + yaml.WriteLine("toc:"); + var markdownFiles = fileSystem.Directory + .EnumerateFiles(root.FullName, "*.md", SearchOption.AllDirectories); + foreach (var markdownFile in markdownFiles) + { + var relative = fileSystem.Path.GetRelativePath(root.FullName, markdownFile); + yaml.WriteLine($" - file: {relative}"); + } + fileSystem.AddFile(Path.Combine(root.FullName, "docset.yml"), new MockFileData(yaml.ToString())); + } + +} diff --git a/tests/Elastic.Markdown.Tests/SiteMap/NavigationTestsBase.cs b/tests/Elastic.Markdown.Tests/SiteMap/NavigationTestsBase.cs index e7bf3661b..5f7d850f8 100644 --- a/tests/Elastic.Markdown.Tests/SiteMap/NavigationTestsBase.cs +++ b/tests/Elastic.Markdown.Tests/SiteMap/NavigationTestsBase.cs @@ -43,7 +43,7 @@ protected NavigationTestsBase(ITestOutputHelper output) public async Task InitializeAsync() { - await Generator.GenerateAll(default); + await Generator.ResolveDirectoryTree(default); Configuration = Generator.DocumentationSet.Configuration; }