From c2e4d6218f2503041c2edd700f4c7557490f9891 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Sat, 22 Feb 2025 12:18:50 +0100 Subject: [PATCH 1/6] Support anchors and table of contents from included files If an included snippet defines it's own headers and anchors they did not contribute to the pages anchors and headers. -- page.md ```markdown :::{include} _snippet/snippet.md ::: ``` This resulted in link check failures to `page.md#included-from-snippet`, as well as the headers from a snippet not appearing on the page's table of contents. This PR also extends our testing framework by allowing to collect data from the conversion using `IConversionCollector`. The `authoring` test framework uses the same `DocumentationGenerator` that `docs-builder` uses. The default for `IConversionCollector` is `null` in real world usage as we don't want the added memory pressure. An upside of this is that we can now really fully test the whole HTML layout vs just the markdown HTML. Lastly this PR includes a tiny fix to DiagnosticLinkInlineParser.cs to always report the resolved path as the full path. That way folks don't need to parse all the parent up `../` instructions themselves when dealing with this error instance. --- .../DocumentationGenerator.cs | 13 ++- src/Elastic.Markdown/IO/DocumentationFile.cs | 33 ++++++- src/Elastic.Markdown/IO/DocumentationSet.cs | 12 +-- src/Elastic.Markdown/IO/MarkdownFile.cs | 88 +++++++++++++---- .../Myst/Directives/IncludeBlock.cs | 2 + .../DiagnosticLinkInlineParser.cs | 2 +- src/Elastic.Markdown/Slices/HtmlWriter.cs | 12 ++- src/Elastic.Markdown/Slices/_ViewModels.cs | 2 +- tests/authoring/Directives/IncludeBlocks.fs | 57 +++++++++++ tests/authoring/Framework/HtmlAssertions.fs | 98 +++---------------- .../Framework/MarkdownDocumentAssertions.fs | 4 + tests/authoring/Framework/Setup.fs | 25 ++--- tests/authoring/Framework/TestValues.fs | 31 ++++-- tests/authoring/Inline/RelativeLinks.fs | 48 +++++++++ tests/authoring/authoring.fsproj | 7 +- 15 files changed, 291 insertions(+), 143 deletions(-) create mode 100644 tests/authoring/Directives/IncludeBlocks.fs create mode 100644 tests/authoring/Inline/RelativeLinks.fs diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 46fe8516f..a348f104c 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -9,12 +9,19 @@ using Elastic.Markdown.IO; using Elastic.Markdown.IO.State; using Elastic.Markdown.Slices; +using Markdig.Syntax; using Microsoft.Extensions.Logging; namespace Elastic.Markdown; +public interface IConversionCollector +{ + void Collect(MarkdownFile file, MarkdownDocument document, string html); +} + public class DocumentationGenerator { + private readonly IConversionCollector? _conversionCollector; private readonly IFileSystem _readFileSystem; private readonly ILogger _logger; private readonly IFileSystem _writeFileSystem; @@ -26,9 +33,11 @@ public class DocumentationGenerator public DocumentationGenerator( DocumentationSet docSet, - ILoggerFactory logger + ILoggerFactory logger, + IConversionCollector? conversionCollector = null ) { + _conversionCollector = conversionCollector; _readFileSystem = docSet.Context.ReadFileSystem; _writeFileSystem = docSet.Context.WriteFileSystem; _logger = logger.CreateLogger(nameof(DocumentationGenerator)); @@ -161,7 +170,7 @@ private async Task ProcessFile(HashSet offendingFiles, DocumentationFile _logger.LogTrace("--> {FileFullPath}", file.SourceFile.FullName); var outputFile = OutputFile(file.RelativePath); if (file is MarkdownFile markdown) - await HtmlWriter.WriteAsync(outputFile, markdown, token); + await HtmlWriter.WriteAsync(outputFile, markdown, _conversionCollector, token); else { if (outputFile.Directory is { Exists: false }) diff --git a/src/Elastic.Markdown/IO/DocumentationFile.cs b/src/Elastic.Markdown/IO/DocumentationFile.cs index bc869678f..2e340b426 100644 --- a/src/Elastic.Markdown/IO/DocumentationFile.cs +++ b/src/Elastic.Markdown/IO/DocumentationFile.cs @@ -2,6 +2,9 @@ // 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 Elastic.Markdown.Myst; +using Elastic.Markdown.Myst.FrontMatter; +using Elastic.Markdown.Slices; namespace Elastic.Markdown.IO; @@ -22,4 +25,32 @@ public record ExcludedFile(IFileInfo SourceFile, IDirectoryInfo RootPath) : DocumentationFile(SourceFile, RootPath); public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath) - : DocumentationFile(SourceFile, RootPath); + : DocumentationFile(SourceFile, RootPath) +{ + private SnippetAnchors? Anchors { get; set; } + private bool _parsed; + + public SnippetAnchors? GetAnchors( + DocumentationSet set, + MarkdownParser parser, + YamlFrontMatter? frontMatter + ) + { + if (_parsed) + return Anchors; + var document = parser.ParseAsync(SourceFile, frontMatter, default).GetAwaiter().GetResult(); + if (!SourceFile.Exists) + { + _parsed = true; + return null; + } + + var toc = MarkdownFile.GetAnchors(set, parser, frontMatter, document, new Dictionary(), out var anchors); + Anchors = new SnippetAnchors(anchors, toc); + _parsed = true; + return Anchors; + } +} + +public record SnippetAnchors(string[] Anchors, IReadOnlyCollection TableOfContentItems); + diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 4ecbca534..cef23342c 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -187,16 +187,16 @@ private DocumentationFile CreateMarkDownFile(IFileInfo file, BuildContext contex if (Configuration.Exclude.Any(g => g.IsMatch(relativePath))) return new ExcludedFile(file, SourcePath); - if (Configuration.Files.Contains(relativePath)) - return new MarkdownFile(file, SourcePath, MarkdownParser, context); - - if (Configuration.Globs.Any(g => g.IsMatch(relativePath))) - return new MarkdownFile(file, SourcePath, MarkdownParser, context); - // we ignore files in folders that start with an underscore if (relativePath.Contains("_snippets")) return new SnippetFile(file, SourcePath); + if (Configuration.Files.Contains(relativePath)) + return new MarkdownFile(file, SourcePath, MarkdownParser, context, this); + + if (Configuration.Globs.Any(g => g.IsMatch(relativePath))) + return new MarkdownFile(file, SourcePath, MarkdownParser, context, this); + // we ignore files in folders that start with an underscore if (relativePath.IndexOf("/_", StringComparison.Ordinal) > 0 || relativePath.StartsWith('_')) return new ExcludedFile(file, SourcePath); diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 3c75a179a..bda7f1630 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -21,7 +21,15 @@ public record MarkdownFile : DocumentationFile { private string? _navigationTitle; - public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext context) + private readonly DocumentationSet _set; + + public MarkdownFile( + IFileInfo sourceFile, + IDirectoryInfo rootPath, + MarkdownParser parser, + BuildContext context, + DocumentationSet set + ) : base(sourceFile, rootPath) { FileName = sourceFile.Name; @@ -30,6 +38,7 @@ public MarkdownFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParse MarkdownParser = parser; Collector = context.Collector; _configurationFile = context.Configuration.SourceFile; + _set = set; } public string Id { get; } = Guid.NewGuid().ToString("N")[..8]; @@ -191,36 +200,73 @@ private void ReadDocumentInstructions(MarkdownDocument document) else if (Title.AsSpan().ReplaceSubstitutions(subs, out var replacement)) Title = replacement; - var contents = document + var toc = GetAnchors(_set, MarkdownParser, YamlFrontMatter, document, subs, out var anchors); + + _tableOfContent.Clear(); + foreach (var t in toc) + _tableOfContent[t.Slug] = t; + + + foreach (var label in anchors) + _ = _anchors.Add(label); + + _instructionsParsed = true; + } + + public static List GetAnchors( + DocumentationSet set, + MarkdownParser parser, + YamlFrontMatter? frontMatter, + MarkdownDocument document, + IReadOnlyDictionary subs, + out string[] anchors) + { + var includeBlocks = document.Descendants().ToArray(); + var includes = includeBlocks + .Select(i => + { + var path = i.IncludePathRelative; + if (path is null + || !set.FlatMappedFiles.TryGetValue(path, out var file) + || file is not SnippetFile snippet) + return null; + + return snippet.GetAnchors(set, parser, frontMatter); + }) + .Where(i => i is not null) + .ToArray(); + + var includedTocs = includes.SelectMany(i => i!.TableOfContentItems).ToArray(); + var toc = document .Descendants() .Where(block => block is { Level: >= 2 }) .Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level)) .Select(h => { var header = h.Item1!.StripMarkdown(); - if (header.AsSpan().ReplaceSubstitutions(subs, out var replacement)) - header = replacement; return new PageTocItem { Heading = header, Slug = (h.Item2 ?? header).Slugify(), Level = h.Level }; }) + .Concat(includedTocs) + .Select(toc => subs.Count == 0 + ? toc + : toc.Heading.AsSpan().ReplaceSubstitutions(subs, out var r) + ? toc with { Heading = r } + : toc) .ToList(); - _tableOfContent.Clear(); - foreach (var t in contents) - _tableOfContent[t.Slug] = t; - - var anchors = document.Descendants() - .Select(b => b.CrossReferenceName) - .Where(l => !string.IsNullOrWhiteSpace(l)) - .Select(s => s.Slugify()) - .Concat(document.Descendants().Select(a => a.Anchor)) - .Concat(_tableOfContent.Values.Select(t => t.Slug)) - .Where(anchor => !string.IsNullOrEmpty(anchor)) - .ToArray(); - - foreach (var label in anchors) - _ = _anchors.Add(label); - - _instructionsParsed = true; + var includedAnchors = includes.SelectMany(i => i!.Anchors).ToArray(); + anchors = + [ + ..document.Descendants() + .Select(b => b.CrossReferenceName) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(s => s.Slugify()) + .Concat(document.Descendants().Select(a => a.Anchor)) + .Concat(toc.Select(t => t.Slug)) + .Where(anchor => !string.IsNullOrEmpty(anchor)) + .Concat(includedAnchors) + ]; + return toc; } private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document) diff --git a/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs b/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs index 30564430a..fe98277c4 100644 --- a/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs @@ -35,6 +35,7 @@ public class IncludeBlock(DirectiveBlockParser parser, ParserContext context) : public YamlFrontMatter? FrontMatter { get; } = context.FrontMatter; public string? IncludePath { get; private set; } + public string? IncludePathRelative { get; private set; } public bool Found { get; private set; } @@ -69,6 +70,7 @@ private void ExtractInclusionPath(ParserContext context) includeFrom = DocumentationSourcePath.FullName; IncludePath = Path.Combine(includeFrom, includePath.TrimStart('/')); + IncludePathRelative = Path.GetRelativePath(includeFrom, IncludePath); if (FileSystem.File.Exists(IncludePath)) Found = true; else diff --git a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs index 8910abe1e..5a4008b71 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs @@ -190,7 +190,7 @@ private static void ValidateInternalUrl(InlineProcessor processor, string url, s if (string.IsNullOrWhiteSpace(url)) return; - var pathOnDisk = Path.Combine(includeFrom, url.TrimStart('/')); + var pathOnDisk = Path.GetFullPath(Path.Combine(includeFrom, url.TrimStart('/'))); if (!context.Build.ReadFileSystem.File.Exists(pathOnDisk)) processor.EmitError(link, $"`{url}` does not exist. resolved to `{pathOnDisk}"); } diff --git a/src/Elastic.Markdown/Slices/HtmlWriter.cs b/src/Elastic.Markdown/Slices/HtmlWriter.cs index 6128d3bfd..988c74b27 100644 --- a/src/Elastic.Markdown/Slices/HtmlWriter.cs +++ b/src/Elastic.Markdown/Slices/HtmlWriter.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; using Elastic.Markdown.IO; +using Markdig.Syntax; using RazorSlices; namespace Elastic.Markdown.Slices; @@ -26,6 +27,11 @@ private async Task RenderNavigation(MarkdownFile markdown, Cancel ctx = public async Task RenderLayout(MarkdownFile markdown, Cancel ctx = default) { var document = await markdown.ParseFullAsync(ctx); + return await RenderLayout(markdown, document, ctx); + } + + public async Task RenderLayout(MarkdownFile markdown, MarkdownDocument document, Cancel ctx = default) + { var html = MarkdownFile.CreateHtml(document); await DocumentationSet.Tree.Resolve(ctx); _renderedNavigation ??= await RenderNavigation(markdown, ctx); @@ -57,7 +63,7 @@ public async Task RenderLayout(MarkdownFile markdown, Cancel ctx = defau return await slice.RenderAsync(cancellationToken: ctx); } - public async Task WriteAsync(IFileInfo outputFile, MarkdownFile markdown, Cancel ctx = default) + public async Task WriteAsync(IFileInfo outputFile, MarkdownFile markdown, IConversionCollector? collector, Cancel ctx = default) { if (outputFile.Directory is { Exists: false }) outputFile.Directory.Create(); @@ -79,7 +85,9 @@ public async Task WriteAsync(IFileInfo outputFile, MarkdownFile markdown, Cancel : Path.Combine(dir, "index.html"); } - var rendered = await RenderLayout(markdown, ctx); + var document = await markdown.ParseFullAsync(ctx); + var rendered = await RenderLayout(markdown, document, ctx); + collector?.Collect(markdown, document, rendered); await writeFileSystem.File.WriteAllTextAsync(path, rendered, ctx); } diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs index e6e54ddd1..f1193d1f5 100644 --- a/src/Elastic.Markdown/Slices/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/_ViewModels.cs @@ -69,7 +69,7 @@ public string Link(string path) } } -public class PageTocItem +public record PageTocItem { public required string Heading { get; init; } public required string Slug { get; init; } diff --git a/tests/authoring/Directives/IncludeBlocks.fs b/tests/authoring/Directives/IncludeBlocks.fs new file mode 100644 index 000000000..a32aa54a4 --- /dev/null +++ b/tests/authoring/Directives/IncludeBlocks.fs @@ -0,0 +1,57 @@ +module ``directive elements``.``include directive`` + +open Swensen.Unquote +open Xunit +open authoring +open authoring.MarkdownDocumentAssertions + +type ``include hoists anchors and table of contents`` () = + + static let generator = Setup.Generate [ + Index """ +# A Document that lives at the root + +:::{include} _snippets/my-snippet.md +::: +""" + Snippet "_snippets/my-snippet.md" """ +## header from snippet [aa] + + """ + Markdown "test-links.md" """ +# parent.md + +## some header +[link to root with included anchor](index.md#aa) + """ + ] + + [] + let ``validate index.md HTML includes snippet`` () = + generator |> converts "index.md" |> toHtml """ +

A Document that lives at the root

+ + """ + + [] + let ``validate test-links.md HTML includes snippet`` () = + generator |> converts "test-links.md" |> toHtml """ +

parent.md

+ +

link to root with included anchor

+ """ + + [] + let ``validate index.md includes table of contents`` () = + let page = generator |> converts "index.md" |> markdownFile + test <@ page.TableOfContents.Count = 1 @> + test <@ page.TableOfContents.ContainsKey("aa") @> + + [] + let ``has no errors`` () = generator |> hasNoErrors + + diff --git a/tests/authoring/Framework/HtmlAssertions.fs b/tests/authoring/Framework/HtmlAssertions.fs index e9a1836ed..7dad69625 100644 --- a/tests/authoring/Framework/HtmlAssertions.fs +++ b/tests/authoring/Framework/HtmlAssertions.fs @@ -7,107 +7,39 @@ namespace authoring open System open System.Diagnostics open System.IO -open AngleSharp.Diffing -open AngleSharp.Diffing.Core open AngleSharp.Html open AngleSharp.Html.Parser -open DiffPlex.DiffBuilder -open DiffPlex.DiffBuilder.Model open JetBrains.Annotations -open Swensen.Unquote open Xunit.Sdk [] module HtmlAssertions = - let htmlDiffString (diffs: seq) = - let NodeName (source:ComparisonSource) = source.Node.NodeType.ToString().ToLowerInvariant(); - let htmlText (source:IDiff) = - let formatter = PrettyMarkupFormatter(); - let nodeText (control: ComparisonSource) = - use sw = new StringWriter() - control.Node.ToHtml(sw, formatter) - sw.ToString() - let attrText (control: AttributeComparisonSource) = - use sw = new StringWriter() - control.Attribute.ToHtml(sw, formatter) - sw.ToString() - let nodeDiffText (control: ComparisonSource option) (test: ComparisonSource option) = - let actual = match test with Some t -> nodeText t | None -> "missing" - let expected = match control with Some t -> nodeText t | None -> "missing" - $""" - -expected: {expected} -actual: {actual} -""" - let attrDiffText (control: AttributeComparisonSource option) (test: AttributeComparisonSource option) = - let actual = match test with Some t -> attrText t | None -> "missing" - let expected = match control with Some t -> attrText t | None -> "missing" - $""" - -expected: {expected} -actual: {actual} -""" - - match source with - | :? NodeDiff as diff -> nodeDiffText <| Some diff.Control <| Some diff.Test - | :? AttrDiff as diff -> attrDiffText <| Some diff.Control <| Some diff.Test - | :? MissingNodeDiff as diff -> nodeDiffText <| Some diff.Control <| None - | :? MissingAttrDiff as diff -> attrDiffText <| Some diff.Control <| None - | :? UnexpectedNodeDiff as diff -> nodeDiffText None <| Some diff.Test - | :? UnexpectedAttrDiff as diff -> attrDiffText None <| Some diff.Test - | _ -> failwith $"Unknown diff type detected: {source.GetType()}" - - diffs - |> Seq.map (fun diff -> - - match diff with - | :? NodeDiff as diff when diff.Target = DiffTarget.Text && diff.Control.Path.Equals(diff.Test.Path, StringComparison.Ordinal) - -> $"The text in {diff.Control.Path} is different." - | :? NodeDiff as diff when diff.Target = DiffTarget.Text - -> $"The expected {NodeName(diff.Control)} at {diff.Control.Path} and the actual {NodeName(diff.Test)} at {diff.Test.Path} is different." - | :? NodeDiff as diff when diff.Control.Path.Equals(diff.Test.Path, StringComparison.Ordinal) - -> $"The {NodeName(diff.Control)}s at {diff.Control.Path} are different." - | :? NodeDiff as diff -> $"The expected {NodeName(diff.Control)} at {diff.Control.Path} and the actual {NodeName(diff.Test)} at {diff.Test.Path} are different." - | :? AttrDiff as diff when diff.Control.Path.Equals(diff.Test.Path, StringComparison.Ordinal) - -> $"The values of the attributes at {diff.Control.Path} are different." - | :? AttrDiff as diff -> $"The value of the attribute {diff.Control.Path} and actual attribute {diff.Test.Path} are different." - | :? MissingNodeDiff as diff -> $"The {NodeName(diff.Control)} at {diff.Control.Path} is missing." - | :? MissingAttrDiff as diff -> $"The attribute at {diff.Control.Path} is missing." - | :? UnexpectedNodeDiff as diff -> $"The {NodeName(diff.Test)} at {diff.Test.Path} was not expected." - | :? UnexpectedAttrDiff as diff -> $"The attribute at {diff.Test.Path} was not expected." - | _ -> failwith $"Unknown diff type detected: {diff.GetType()}" - + - htmlText diff - ) - |> String.concat "\n" - - let private prettyHtml (html:string) = + let private prettyHtml (html:string) (querySelector: string option) = let parser = HtmlParser() let document = parser.ParseDocument(html) + let element = + match querySelector with + | Some q -> document.QuerySelector q + | None -> document.Body use sw = new StringWriter() - document.Body.Children + element.Children |> Seq.iter _.ToHtml(sw, PrettyMarkupFormatter()) sw.ToString().TrimStart('\n') let private createDiff expected actual = - let diffs = - DiffBuilder - .Compare(actual) - .WithTest(expected) - .Build() - - let deepComparision = htmlDiffString diffs - match deepComparision with + let expectedHtml = prettyHtml expected None + let actualHtml = prettyHtml actual (Some "section#elastic-docs-v3") + let textDiff = diff expectedHtml actualHtml + match textDiff with | s when String.IsNullOrEmpty s -> () | s -> - let expectedHtml = prettyHtml expected - let actualHtml = prettyHtml actual - let textDiff = diff expectedHtml actualHtml let msg = $"""Html was not equal +-- DIFF -- {textDiff} -{deepComparision} +-- Actual HTML -- +{actualHtml} """ raise (XunitException(msg)) @@ -125,8 +57,8 @@ actual: {actual} [] let containsHtml ([]expected: string) (actual: MarkdownResult) = - let prettyExpected = prettyHtml expected - let prettyActual = prettyHtml actual.Html + let prettyExpected = prettyHtml expected None + let prettyActual = prettyHtml actual.Html (Some "section#elastic-docs-v3") if not <| prettyActual.Contains prettyExpected then let msg = $"""Expected html to contain: diff --git a/tests/authoring/Framework/MarkdownDocumentAssertions.fs b/tests/authoring/Framework/MarkdownDocumentAssertions.fs index 781d5e8cb..ee0777d1a 100644 --- a/tests/authoring/Framework/MarkdownDocumentAssertions.fs +++ b/tests/authoring/Framework/MarkdownDocumentAssertions.fs @@ -48,3 +48,7 @@ module MarkdownDocumentAssertions = test <@ apply = expectedAvailability @> | _ -> failwithf "Could not locate an AppliesToDirective" + [] + let markdownFile (actual: MarkdownResult) = + actual.File + diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index c2850add7..fe2813dc9 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -25,6 +25,7 @@ type Markdown = string type TestFile = | File of name: string * contents: string | MarkdownFile of name: string * markdown: Markdown + | SnippetFile of name: string * markdown: Markdown static member Index ([] m) = MarkdownFile("index.md" , m) @@ -32,6 +33,9 @@ type TestFile = static member Markdown path ([] m) = MarkdownFile(path , m) + static member Snippet path ([] m) = + SnippetFile(path , m) + type Setup = static let GenerateDocSetYaml( @@ -82,6 +86,7 @@ type Setup = |> Seq.map (fun f -> match f with | File(name, contents) -> ($"docs/{name}", MockFileData(contents)) + | SnippetFile(name, markdown) -> ($"docs/{name}", MockFileData(markdown)) | MarkdownFile(name, markdown) -> ($"docs/{name}", MockFileData(markdown)) ) |> Map.ofSeq @@ -94,28 +99,14 @@ type Setup = let collector = TestDiagnosticsCollector(); let context = BuildContext(collector, fileSystem) let logger = new TestLoggerFactory() + let conversionCollector = TestConversionCollector() let linkResolver = TestCrossLinkResolver(context.Configuration) let set = DocumentationSet(context, logger, linkResolver); - let generator = DocumentationGenerator(set, logger) - - let markdownFiles = - files - |> Seq.map (fun f -> - match f with - | File _ -> None - | MarkdownFile(name, _) -> Some $"docs/{name}" - ) - |> Seq.choose id - |> Seq.map (fun f -> - match set.GetMarkdownFile(fileSystem.FileInfo.New(f)) with - | NonNull m -> Some m - | _ -> None - ) - |> Seq.choose id + let generator = DocumentationGenerator(set, logger, conversionCollector) let context = { - MarkdownFiles = markdownFiles Collector = collector + ConversionCollector= conversionCollector Set = set Generator = generator ReadFileSystem = fileSystem diff --git a/tests/authoring/Framework/TestValues.fs b/tests/authoring/Framework/TestValues.fs index 8399a7f25..7dc33ad07 100644 --- a/tests/authoring/Framework/TestValues.fs +++ b/tests/authoring/Framework/TestValues.fs @@ -5,6 +5,7 @@ namespace authoring open System +open System.Collections.Concurrent open System.IO.Abstractions open Elastic.Markdown open Elastic.Markdown.Diagnostics @@ -59,6 +60,20 @@ type TestLoggerFactory () = member this.Dispose() = () +type ConversionResult = { + File: MarkdownFile + Document: MarkdownDocument + Html: string +} + +type TestConversionCollector () = + let x = ConcurrentDictionary() + member this.Results = x + interface IConversionCollector with + member this.Collect(file, document, html) = + this.Results.TryAdd(file.RelativePath, { File= file; Document=document;Html=html}) |> ignore + + type MarkdownResult = { File: MarkdownFile MinimalParse: MarkdownDocument @@ -73,8 +88,8 @@ and GeneratorResults = { and MarkdownTestContext = { - MarkdownFiles: MarkdownFile seq Collector: TestDiagnosticsCollector + ConversionCollector: TestConversionCollector Set: DocumentationSet Generator: DocumentationGenerator ReadFileSystem: IFileSystem @@ -86,13 +101,13 @@ and MarkdownTestContext = do! this.Generator.GenerateAll(ctx) let results = - this.MarkdownFiles - |> Seq.map (fun (f: MarkdownFile) -> task { - // technically we do this work twice since generate all also does it - let! document = f.ParseFullAsync(ctx) - let! minimal = f.MinimalParseAsync(ctx) - let html = MarkdownFile.CreateHtml(document) - return { File = f; Document = document; MinimalParse = minimal; Html = html; Context = this } + this.ConversionCollector.Results + |> Seq.map (fun kv -> task { + let file = kv.Value.File + let document = kv.Value.Document + let html = kv.Value.Html + let! minimal = kv.Value.File.MinimalParseAsync(ctx) + return { File = file; Document = document; MinimalParse = minimal; Html = html; Context = this } }) // this is not great code, refactor or depend on FSharp.Control.TaskSeq // for now this runs without issue diff --git a/tests/authoring/Inline/RelativeLinks.fs b/tests/authoring/Inline/RelativeLinks.fs new file mode 100644 index 000000000..a3512e027 --- /dev/null +++ b/tests/authoring/Inline/RelativeLinks.fs @@ -0,0 +1,48 @@ +module ``inline elemenents``.``relative links`` + +open Xunit +open authoring + +type ``two pages with anchors end up in artifact`` () = + + static let generator = Setup.Generate [ + Index """ +# A Document that lives at the root + +*Welcome* to this documentation + +## This anchor is autogenerated + +### Several pages can be created [#and-anchored] + +Through various means $$$including-this-inline-syntax$$$ +""" + Markdown "deeply/nested/file.md" """ +# file.md + +[link to root](../../index.md#and-anchored) + +[link to parent](../parent.md) + +[link to parent](../parent.md#some-header) + """ + Markdown "deeply/parent.md" """ +# parent.md + +## some header +[link to root](../index.md) + """ + ] + + [] + let ``validate index.md HTML`` () = + generator |> converts "deeply/nested/file.md" |> toHtml """ +

link to root

+

link to parent

+

link to parent

+ """ + + [] + let ``has no errors`` () = generator |> hasNoErrors + + diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index 339647ad9..3449e533a 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -21,7 +21,7 @@ - + @@ -51,6 +51,7 @@ + @@ -66,4 +67,8 @@ + + + + From eac8a6d3852448006651067f69e55db4567d4adb Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Sat, 22 Feb 2025 13:00:32 +0100 Subject: [PATCH 2/6] Add back AngleSharp.Diffing --- src/Elastic.Markdown/IO/DocumentationFile.cs | 2 +- .../Myst/Directives/DirectiveHtmlRenderer.cs | 2 - tests/authoring/Directives/IncludeBlocks.fs | 23 +++++ tests/authoring/Framework/HtmlAssertions.fs | 98 +++++++++++++++++-- .../Framework/MarkdownDocumentAssertions.fs | 2 +- tests/authoring/authoring.fsproj | 2 +- 6 files changed, 114 insertions(+), 15 deletions(-) diff --git a/src/Elastic.Markdown/IO/DocumentationFile.cs b/src/Elastic.Markdown/IO/DocumentationFile.cs index 2e340b426..5bc44b80a 100644 --- a/src/Elastic.Markdown/IO/DocumentationFile.cs +++ b/src/Elastic.Markdown/IO/DocumentationFile.cs @@ -38,13 +38,13 @@ public record SnippetFile(IFileInfo SourceFile, IDirectoryInfo RootPath) { if (_parsed) return Anchors; - var document = parser.ParseAsync(SourceFile, frontMatter, default).GetAwaiter().GetResult(); if (!SourceFile.Exists) { _parsed = true; return null; } + var document = parser.MinimalParseAsync(SourceFile, default).GetAwaiter().GetResult(); var toc = MarkdownFile.GetAnchors(set, parser, frontMatter, document, new Dictionary(), out var anchors); Anchors = new SnippetAnchors(anchors, toc); _parsed = true; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 420fff70f..ae2a2315b 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -231,8 +231,6 @@ private static void WriteIncludeBlock(HtmlRenderer renderer, IncludeBlock block) var document = parser.ParseAsync(file, block.FrontMatter, default).GetAwaiter().GetResult(); var html = document.ToHtml(MarkdownParser.Pipeline); _ = renderer.Write(html); - //var slice = Include.Create(new IncludeViewModel { Html = html }); - //RenderRazorSlice(slice, renderer, block); } private static void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock block) diff --git a/tests/authoring/Directives/IncludeBlocks.fs b/tests/authoring/Directives/IncludeBlocks.fs index a32aa54a4..8c30b63b0 100644 --- a/tests/authoring/Directives/IncludeBlocks.fs +++ b/tests/authoring/Directives/IncludeBlocks.fs @@ -55,3 +55,26 @@ type ``include hoists anchors and table of contents`` () = let ``has no errors`` () = generator |> hasNoErrors +type ``include can contain links to parent page's includes`` () = + + static let generator = Setup.Generate [ + Index """ +# A Document that lives at the root + +:::{include} _snippets/my-snippet.md +::: + +:::{include} _snippets/my-other-snippet.md +::: +""" + Snippet "_snippets/my-snippet.md" """ +## header from snippet [aa] + """ + + Snippet "_snippets/my-other-snippet.md" """ +[link to root with included anchor](../index.md#aa) + """ + ] + + [] + let ``has no errors`` () = generator |> hasNoErrors diff --git a/tests/authoring/Framework/HtmlAssertions.fs b/tests/authoring/Framework/HtmlAssertions.fs index 7dad69625..8fb3011ea 100644 --- a/tests/authoring/Framework/HtmlAssertions.fs +++ b/tests/authoring/Framework/HtmlAssertions.fs @@ -7,14 +7,81 @@ namespace authoring open System open System.Diagnostics open System.IO +open AngleSharp.Diffing +open AngleSharp.Diffing.Core open AngleSharp.Html open AngleSharp.Html.Parser +open DiffPlex.DiffBuilder +open DiffPlex.DiffBuilder.Model open JetBrains.Annotations +open Swensen.Unquote open Xunit.Sdk [] module HtmlAssertions = + let htmlDiffString (diffs: seq) = + let NodeName (source:ComparisonSource) = source.Node.NodeType.ToString().ToLowerInvariant(); + let htmlText (source:IDiff) = + let formatter = PrettyMarkupFormatter(); + let nodeText (control: ComparisonSource) = + use sw = new StringWriter() + control.Node.ToHtml(sw, formatter) + sw.ToString() + let attrText (control: AttributeComparisonSource) = + use sw = new StringWriter() + control.Attribute.ToHtml(sw, formatter) + sw.ToString() + let nodeDiffText (control: ComparisonSource option) (test: ComparisonSource option) = + let actual = match test with Some t -> nodeText t | None -> "missing" + let expected = match control with Some t -> nodeText t | None -> "missing" + $""" + +expected: {expected} +actual: {actual} +""" + let attrDiffText (control: AttributeComparisonSource option) (test: AttributeComparisonSource option) = + let actual = match test with Some t -> attrText t | None -> "missing" + let expected = match control with Some t -> attrText t | None -> "missing" + $""" + +expected: {expected} +actual: {actual} +""" + + match source with + | :? NodeDiff as diff -> nodeDiffText <| Some diff.Control <| Some diff.Test + | :? AttrDiff as diff -> attrDiffText <| Some diff.Control <| Some diff.Test + | :? MissingNodeDiff as diff -> nodeDiffText <| Some diff.Control <| None + | :? MissingAttrDiff as diff -> attrDiffText <| Some diff.Control <| None + | :? UnexpectedNodeDiff as diff -> nodeDiffText None <| Some diff.Test + | :? UnexpectedAttrDiff as diff -> attrDiffText None <| Some diff.Test + | _ -> failwith $"Unknown diff type detected: {source.GetType()}" + + diffs + |> Seq.map (fun diff -> + + match diff with + | :? NodeDiff as diff when diff.Target = DiffTarget.Text && diff.Control.Path.Equals(diff.Test.Path, StringComparison.Ordinal) + -> $"The text in {diff.Control.Path} is different." + | :? NodeDiff as diff when diff.Target = DiffTarget.Text + -> $"The expected {NodeName(diff.Control)} at {diff.Control.Path} and the actual {NodeName(diff.Test)} at {diff.Test.Path} is different." + | :? NodeDiff as diff when diff.Control.Path.Equals(diff.Test.Path, StringComparison.Ordinal) + -> $"The {NodeName(diff.Control)}s at {diff.Control.Path} are different." + | :? NodeDiff as diff -> $"The expected {NodeName(diff.Control)} at {diff.Control.Path} and the actual {NodeName(diff.Test)} at {diff.Test.Path} are different." + | :? AttrDiff as diff when diff.Control.Path.Equals(diff.Test.Path, StringComparison.Ordinal) + -> $"The values of the attributes at {diff.Control.Path} are different." + | :? AttrDiff as diff -> $"The value of the attribute {diff.Control.Path} and actual attribute {diff.Test.Path} are different." + | :? MissingNodeDiff as diff -> $"The {NodeName(diff.Control)} at {diff.Control.Path} is missing." + | :? MissingAttrDiff as diff -> $"The attribute at {diff.Control.Path} is missing." + | :? UnexpectedNodeDiff as diff -> $"The {NodeName(diff.Test)} at {diff.Test.Path} was not expected." + | :? UnexpectedAttrDiff as diff -> $"The attribute at {diff.Test.Path} was not expected." + | _ -> failwith $"Unknown diff type detected: {diff.GetType()}" + + + htmlText diff + ) + |> String.concat "\n" + let private prettyHtml (html:string) (querySelector: string option) = let parser = HtmlParser() let document = parser.ParseDocument(html) @@ -23,35 +90,46 @@ module HtmlAssertions = | Some q -> document.QuerySelector q | None -> document.Body use sw = new StringWriter() + let formatter = PrettyMarkupFormatter() element.Children - |> Seq.iter _.ToHtml(sw, PrettyMarkupFormatter()) + |> Seq.indexed + |> Seq.filter (fun (i, c) -> (not <| (i = 0 && c.TagName = "H1"))) + |> Seq.map(fun (_, c) -> c) + |> Seq.iter _.ToHtml(sw, formatter) sw.ToString().TrimStart('\n') let private createDiff expected actual = - let expectedHtml = prettyHtml expected None - let actualHtml = prettyHtml actual (Some "section#elastic-docs-v3") - let textDiff = diff expectedHtml actualHtml - match textDiff with + let diffs = + DiffBuilder + .Compare(actual) + .WithTest(expected) + .Build() + + let deepComparision = htmlDiffString diffs + match deepComparision with | s when String.IsNullOrEmpty s -> () | s -> + let textDiff = diff expected actual let msg = $"""Html was not equal -- DIFF -- {textDiff} --- Actual HTML -- -{actualHtml} +-- Comparison -- +{deepComparision} """ raise (XunitException(msg)) [] let toHtml ([]expected: string) (actual: MarkdownResult) = - createDiff expected actual.Html + let expectedHtml = prettyHtml expected None + let actualHtml = prettyHtml actual.Html (Some "section#elastic-docs-v3") + createDiff expectedHtml actualHtml [] let convertsToHtml ([]expected: string) (actual: Lazy) = let actual = actual.Value - let defaultFile = actual.MarkdownResults |> Seq.head + let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md") defaultFile |> toHtml expected [] @@ -75,5 +153,5 @@ But was not found in: let convertsToContainingHtml ([]expected: string) (actual: Lazy) = let actual = actual.Value - let defaultFile = actual.MarkdownResults |> Seq.head + let defaultFile = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md") defaultFile |> containsHtml expected diff --git a/tests/authoring/Framework/MarkdownDocumentAssertions.fs b/tests/authoring/Framework/MarkdownDocumentAssertions.fs index ee0777d1a..d5be69881 100644 --- a/tests/authoring/Framework/MarkdownDocumentAssertions.fs +++ b/tests/authoring/Framework/MarkdownDocumentAssertions.fs @@ -30,7 +30,7 @@ module MarkdownDocumentAssertions = [] let appliesTo (expectedAvailability: ApplicableTo) (actual: Lazy) = let actual = actual.Value - let result = actual.MarkdownResults |> Seq.head + let result = actual.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md") let matter = result.File.YamlFrontMatter match matter with | NonNull m -> diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index 3449e533a..34ef73f6e 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -21,7 +21,7 @@ - + From 35ef39e01a54267827d38c2c280bb2b5c2d92aa1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Sat, 22 Feb 2025 13:18:16 +0100 Subject: [PATCH 3/6] ensure we lookup snippets anchored to the root --- src/Elastic.Markdown/IO/MarkdownFile.cs | 6 +++++- src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index bda7f1630..f7ce6493d 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -200,6 +200,10 @@ private void ReadDocumentInstructions(MarkdownDocument document) else if (Title.AsSpan().ReplaceSubstitutions(subs, out var replacement)) Title = replacement; + if (RelativePath.Contains("esql-functions-operators")) + { + + } var toc = GetAnchors(_set, MarkdownParser, YamlFrontMatter, document, subs, out var anchors); _tableOfContent.Clear(); @@ -225,7 +229,7 @@ public static List GetAnchors( var includes = includeBlocks .Select(i => { - var path = i.IncludePathRelative; + var path = i.IncludePathFromSourceDirectory; if (path is null || !set.FlatMappedFiles.TryGetValue(path, out var file) || file is not SnippetFile snippet) diff --git a/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs b/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs index fe98277c4..da258b790 100644 --- a/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/IncludeBlock.cs @@ -35,7 +35,7 @@ public class IncludeBlock(DirectiveBlockParser parser, ParserContext context) : public YamlFrontMatter? FrontMatter { get; } = context.FrontMatter; public string? IncludePath { get; private set; } - public string? IncludePathRelative { get; private set; } + public string? IncludePathFromSourceDirectory { get; private set; } public bool Found { get; private set; } @@ -70,7 +70,7 @@ private void ExtractInclusionPath(ParserContext context) includeFrom = DocumentationSourcePath.FullName; IncludePath = Path.Combine(includeFrom, includePath.TrimStart('/')); - IncludePathRelative = Path.GetRelativePath(includeFrom, IncludePath); + IncludePathFromSourceDirectory = Path.GetRelativePath(DocumentationSourcePath.FullName, IncludePath); if (FileSystem.File.Exists(IncludePath)) Found = true; else From 1b2505081b4aeff87f21e9369de9df91660dd323 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Sat, 22 Feb 2025 13:20:27 +0100 Subject: [PATCH 4/6] fix license headers --- tests/authoring/Directives/IncludeBlocks.fs | 4 ++++ tests/authoring/Inline/RelativeLinks.fs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/authoring/Directives/IncludeBlocks.fs b/tests/authoring/Directives/IncludeBlocks.fs index 8c30b63b0..6577abb69 100644 --- a/tests/authoring/Directives/IncludeBlocks.fs +++ b/tests/authoring/Directives/IncludeBlocks.fs @@ -1,3 +1,7 @@ +// 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 + module ``directive elements``.``include directive`` open Swensen.Unquote diff --git a/tests/authoring/Inline/RelativeLinks.fs b/tests/authoring/Inline/RelativeLinks.fs index a3512e027..3e38a3441 100644 --- a/tests/authoring/Inline/RelativeLinks.fs +++ b/tests/authoring/Inline/RelativeLinks.fs @@ -1,3 +1,7 @@ +// 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 + module ``inline elemenents``.``relative links`` open Xunit From f486d5ac85870cc7123af2592eeffd7ae95a5c54 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Sat, 22 Feb 2025 13:51:44 +0100 Subject: [PATCH 5/6] don't lookup anchors in includes that are not found (or are cyclical) --- src/Elastic.Markdown/IO/MarkdownFile.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index f7ce6493d..5bac78c60 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -227,6 +227,7 @@ public static List GetAnchors( { var includeBlocks = document.Descendants().ToArray(); var includes = includeBlocks + .Where(i => i.Found) .Select(i => { var path = i.IncludePathFromSourceDirectory; From f33e9cb08f163a5ffa0939195ad06fc7021de909 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Sat, 22 Feb 2025 15:23:54 +0100 Subject: [PATCH 6/6] remove htmx tags from test assertions for now --- tests/authoring/Framework/HtmlAssertions.fs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/authoring/Framework/HtmlAssertions.fs b/tests/authoring/Framework/HtmlAssertions.fs index 8fb3011ea..7fa7afe61 100644 --- a/tests/authoring/Framework/HtmlAssertions.fs +++ b/tests/authoring/Framework/HtmlAssertions.fs @@ -9,6 +9,7 @@ open System.Diagnostics open System.IO open AngleSharp.Diffing open AngleSharp.Diffing.Core +open AngleSharp.Dom open AngleSharp.Html open AngleSharp.Html.Parser open DiffPlex.DiffBuilder @@ -89,6 +90,17 @@ actual: {actual} match querySelector with | Some q -> document.QuerySelector q | None -> document.Body + + let links = element.QuerySelectorAll("a") + links + |> Seq.iter(fun l -> + l.RemoveAttribute "hx-select-oob" |> ignore + l.RemoveAttribute "hx-swap" |> ignore + l.RemoveAttribute "hx-indicator" |> ignore + l.RemoveAttribute "hx-push-url" |> ignore + l.RemoveAttribute "preload" |> ignore + ) + use sw = new StringWriter() let formatter = PrettyMarkupFormatter() element.Children