Skip to content

Commit 9f6ef78

Browse files
committed
Add inline link/image validation
1 parent a6a56b9 commit 9f6ef78

File tree

16 files changed

+300
-47
lines changed

16 files changed

+300
-47
lines changed

src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public static void EmitError(this InlineProcessor processor, int line, int colum
2828
}
2929

3030

31-
public static void EmitWarning(this BlockProcessor processor, int line, int column, int length, string message)
31+
public static void EmitWarning(this InlineProcessor processor, int line, int column, int length, string message)
3232
{
3333
var context = processor.GetContext();
3434
if (context.SkipValidation) return;

src/Elastic.Markdown/Elastic.Markdown.csproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,4 @@
2626
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
2727
</ItemGroup>
2828

29-
<ItemGroup>
30-
<Folder Include="Myst\Inline\" />
31-
</ItemGroup>
32-
3329
</Project>

src/Elastic.Markdown/IO/DocumentationSet.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,30 @@ public class DocumentationSet
1919

2020
public ConfigurationFile Configuration { get; }
2121

22-
private MarkdownParser MarkdownParser { get; }
22+
public MarkdownParser MarkdownParser { get; }
2323

2424
public DocumentationSet(BuildContext context) : this(null, null, context) { }
2525

2626
public DocumentationSet(IDirectoryInfo? sourcePath, IDirectoryInfo? outputPath, BuildContext context)
2727
{
2828
SourcePath = sourcePath ?? context.ReadFileSystem.DirectoryInfo.New(Path.Combine(Paths.Root.FullName, "docs/source"));
2929
OutputPath = outputPath ?? context.WriteFileSystem.DirectoryInfo.New(Path.Combine(Paths.Root.FullName, ".artifacts/docs/html"));
30+
31+
var configurationFile = SourcePath.EnumerateFiles("docset.yml", SearchOption.AllDirectories).FirstOrDefault();
32+
if (configurationFile is null)
33+
{
34+
configurationFile = context.ReadFileSystem.FileInfo.New(Path.Combine(SourcePath.FullName, "docset.yml"));
35+
context.EmitWarning(configurationFile, "No configuration file found");
36+
}
37+
38+
if (configurationFile.Directory!.FullName != SourcePath.FullName)
39+
SourcePath = configurationFile.Directory;
40+
41+
MarkdownParser = new MarkdownParser(SourcePath, context, GetTitle);
42+
3043
Name = SourcePath.FullName;
31-
MarkdownParser = new MarkdownParser(SourcePath, context);
3244
OutputStateFile = OutputPath.FileSystem.FileInfo.New(Path.Combine(OutputPath.FullName, ".doc.state"));
3345

34-
var configurationFile = context.ReadFileSystem.FileInfo.New(Path.Combine(SourcePath.FullName, "docset.yml"));
3546
Configuration = new ConfigurationFile(configurationFile, SourcePath, context);
3647

3748
Files = context.ReadFileSystem.Directory
@@ -57,6 +68,18 @@ public DocumentationSet(IDirectoryInfo? sourcePath, IDirectoryInfo? outputPath,
5768
Tree = new DocumentationFolder(Configuration.TableOfContents, FlatMappedFiles, folderFiles);
5869
}
5970

71+
private string? GetTitle(string relativePath) => GetMarkdownFile(relativePath)?.YamlFrontMatter?.Title;
72+
73+
public MarkdownFile? GetMarkdownFile(string relativePath)
74+
{
75+
if (FlatMappedFiles.TryGetValue(relativePath, out var file) && file is MarkdownFile markdownFile)
76+
return markdownFile;
77+
return null;
78+
}
79+
80+
public async Task ResolveDirectoryTree(Cancel ctx) =>
81+
await Tree.Resolve(ctx);
82+
6083
private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext context)
6184
{
6285
if (Configuration.Exclude.Any(g => g.IsMatch(file.Name)))

src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ private void WriteIncludeBlock(HtmlRenderer renderer, IncludeBlock block)
208208
if (!block.Found || block.IncludePath is null)
209209
return;
210210

211-
var parser = new MarkdownParser(block.DocumentationSourcePath, block.Build);
211+
var parser = new MarkdownParser(block.DocumentationSourcePath, block.Build, block.GetTitle);
212212
var file = block.FileSystem.FileInfo.New(block.IncludePath);
213213
var document = parser.ParseAsync(file, block.FrontMatter, default).GetAwaiter().GetResult();
214214
var html = document.ToHtml(parser.Pipeline);

src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ public class IncludeBlock(DirectiveBlockParser parser, Dictionary<string, string
1313

1414
public BuildContext Build { get; } = context.Build;
1515

16+
public Func<string, string?>? GetTitle { get; } = context.GetTitle;
17+
1618
public IFileSystem FileSystem { get; } = context.Build.ReadFileSystem;
1719

1820
public IDirectoryInfo DocumentationSourcePath { get; } = context.Parser.SourcePath;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Elastic.Markdown.Diagnostics;
6+
using Elastic.Markdown.Myst.Directives;
7+
using Markdig;
8+
using Markdig.Helpers;
9+
using Markdig.Parsers;
10+
using Markdig.Parsers.Inlines;
11+
using Markdig.Renderers;
12+
using Markdig.Syntax.Inlines;
13+
14+
namespace Elastic.Markdown.Myst.InlineParsers;
15+
16+
public static class DirectiveMarkdownBuilderExtensions
17+
{
18+
public static MarkdownPipelineBuilder UseDiagnosticLinks(this MarkdownPipelineBuilder pipeline)
19+
{
20+
pipeline.Extensions.AddIfNotAlready<DiagnosticLinkInlineExtensions>();
21+
return pipeline;
22+
}
23+
}
24+
25+
public class DiagnosticLinkInlineExtensions : IMarkdownExtension
26+
{
27+
public void Setup(MarkdownPipelineBuilder pipeline) =>
28+
pipeline.InlineParsers.Replace<LinkInlineParser>(new DiagnosticLinkInlineParser());
29+
30+
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }
31+
}
32+
33+
public class DiagnosticLinkInlineParser : LinkInlineParser
34+
{
35+
public override bool Match(InlineProcessor processor, ref StringSlice slice)
36+
{
37+
var match = base.Match(processor, ref slice);
38+
if (!match) return false;
39+
40+
if (processor.Inline is not LinkInline link)
41+
return match;
42+
43+
var url = link.Url;
44+
var line = link.Line + 1;
45+
var column = link.Column;
46+
var length = url?.Length ?? 1;
47+
48+
var context = processor.GetContext();
49+
if (processor.GetContext().SkipValidation)
50+
return match;
51+
52+
if (string.IsNullOrEmpty(url))
53+
{
54+
processor.EmitWarning(line, column, length, $"Found empty url");
55+
return match;
56+
}
57+
58+
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme.StartsWith("http"))
59+
{
60+
processor.EmitWarning(line, column, length, $"external URI: {uri} ");
61+
return match;
62+
}
63+
64+
var includeFrom = context.Path.Directory!.FullName;
65+
if (url.StartsWith('/'))
66+
includeFrom = context.Parser.SourcePath.FullName;
67+
68+
var pathOnDisk = Path.Combine(includeFrom, url.TrimStart('/'));
69+
if (!context.Build.ReadFileSystem.File.Exists(pathOnDisk))
70+
processor.EmitError(line, column, length, $"`{url}` does not exist. resolved to `{pathOnDisk}");
71+
72+
if (link.FirstChild == null)
73+
{
74+
var title = context.GetTitle?.Invoke(url);
75+
if (!string.IsNullOrEmpty(title))
76+
link.AppendChild(new LiteralInline(title));
77+
}
78+
79+
if (url.EndsWith(".md"))
80+
link.Url = Path.ChangeExtension(url, ".html");
81+
82+
return match;
83+
84+
85+
86+
}
87+
}

src/Elastic.Markdown/Myst/MarkdownParser.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,31 @@
66
using Cysharp.IO;
77
using Elastic.Markdown.Myst.Comments;
88
using Elastic.Markdown.Myst.Directives;
9+
using Elastic.Markdown.Myst.InlineParsers;
910
using Elastic.Markdown.Myst.Substitution;
1011
using Markdig;
1112
using Markdig.Extensions.EmphasisExtras;
1213
using Markdig.Syntax;
1314

1415
namespace Elastic.Markdown.Myst;
1516

16-
public class MarkdownParser(IDirectoryInfo sourcePath, BuildContext context)
17+
public class MarkdownParser(IDirectoryInfo sourcePath, BuildContext context, Func<string, string?>? getTitle)
1718
{
1819
public IDirectoryInfo SourcePath { get; } = sourcePath;
1920
public BuildContext Context { get; } = context;
2021

2122
public MarkdownPipeline MinimalPipeline { get; } =
2223
new MarkdownPipelineBuilder()
24+
.UseDiagnosticLinks()
2325
.UseSubstitution()
2426
.UseYamlFrontMatter()
2527
.Build();
2628

27-
public MarkdownPipeline Pipeline { get; } =
29+
public MarkdownPipeline Pipeline =>
2830
new MarkdownPipelineBuilder()
2931
.EnableTrackTrivia()
3032
.UsePreciseSourceLocation()
33+
.UseDiagnosticLinks()
3134
.UseGenericAttributes()
3235
.UseEmphasisExtras(EmphasisExtraOptions.Default)
3336
.UseSoftlineBreakAsHardlineBreak()
@@ -43,13 +46,20 @@ public class MarkdownParser(IDirectoryInfo sourcePath, BuildContext context)
4346

4447
public Task<MarkdownDocument> MinimalParseAsync(IFileInfo path, Cancel ctx)
4548
{
46-
var context = new ParserContext(this, path, null, Context) { SkipValidation = true };
49+
var context = new ParserContext(this, path, null, Context)
50+
{
51+
SkipValidation = true,
52+
GetTitle = getTitle
53+
};
4754
return ParseAsync(path, context, MinimalPipeline, ctx);
4855
}
4956

5057
public Task<MarkdownDocument> ParseAsync(IFileInfo path, YamlFrontMatter? matter, Cancel ctx)
5158
{
52-
var context = new ParserContext(this, path, matter, Context);
59+
var context = new ParserContext(this, path, matter, Context)
60+
{
61+
GetTitle = getTitle
62+
};
5363
return ParseAsync(path, context, Pipeline, ctx);
5464
}
5565

src/Elastic.Markdown/Myst/ParserContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ public ParserContext(MarkdownParser markdownParser,
5252
public YamlFrontMatter? FrontMatter { get; }
5353
public BuildContext Build { get; }
5454
public bool SkipValidation { get; init; }
55+
public Func<string, string?>? GetTitle { get; init; }
5556
}

tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public abstract class DirectiveTest : IAsyncLifetime
5151
protected MarkdownDocument Document { get; private set; }
5252
protected MockFileSystem FileSystem { get; }
5353
protected TestDiagnosticsCollector Collector { get; }
54+
protected DocumentationSet Set { get; set; }
55+
5456

5557
protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown")]string content)
5658
{
@@ -62,23 +64,28 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown")
6264
{
6365
CurrentDirectory = Paths.Root.FullName
6466
});
67+
// ReSharper disable once VirtualMemberCallInConstructor
68+
// nasty but sub implementations won't use class state.
69+
AddToFileSystem(FileSystem);
70+
71+
var root = FileSystem.DirectoryInfo.New(Path.Combine(Paths.Root.FullName, "docs/source"));
72+
FileSystem.GenerateDocSetYaml(root);
6573

66-
var file = FileSystem.FileInfo.New("docs/source/index.md");
67-
var root = file.Directory!;
6874
Collector = new TestDiagnosticsCollector(logger);
6975
var context = new BuildContext
7076
{
7177
ReadFileSystem = FileSystem,
7278
WriteFileSystem = FileSystem,
7379
Collector = Collector
7480
};
75-
var parser = new MarkdownParser(root, context);
76-
77-
File = new MarkdownFile(file, root, parser, context);
81+
Set = new DocumentationSet(null, null, context);
82+
File = Set.GetMarkdownFile("index.md") ?? throw new NullReferenceException();
7883
Html = default!; //assigned later
7984
Document = default!;
8085
}
8186

87+
protected virtual void AddToFileSystem(MockFileSystem fileSystem) { }
88+
8289
public virtual async Task InitializeAsync()
8390
{
8491
var collectTask = Task.Run(async () => await Collector.StartAsync(default), default);

tests/Elastic.Markdown.Tests/Directives/ImageTests.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using System.IO.Abstractions.TestingHelpers;
56
using Elastic.Markdown.Diagnostics;
67
using Elastic.Markdown.Myst.Directives;
78
using FluentAssertions;
@@ -18,11 +19,8 @@ public class ImageBlockTests(ITestOutputHelper output) : DirectiveTest<ImageBloc
1819
"""
1920
)
2021
{
21-
public override Task InitializeAsync()
22-
{
23-
FileSystem.AddFile(@"docs/source/img/observability.png", "");
24-
return base.InitializeAsync();
25-
}
22+
protected override void AddToFileSystem(MockFileSystem fileSystem) =>
23+
fileSystem.AddFile(@"docs/source/img/observability.png", "");
2624

2725
[Fact]
2826
public void ParsesBlock() => Block.Should().NotBeNull();

0 commit comments

Comments
 (0)