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;
}