diff --git a/src/Elastic.Markdown/Assets/main.ts b/src/Elastic.Markdown/Assets/main.ts index 71b5ad68d..1a9229463 100644 --- a/src/Elastic.Markdown/Assets/main.ts +++ b/src/Elastic.Markdown/Assets/main.ts @@ -1,6 +1,8 @@ // @ts-nocheck import "htmx.org" import "htmx-ext-preload" +import "htmx-ext-head-support" + import {initTocNav} from "./toc-nav"; import {initHighlight} from "./hljs"; import {initTabs} from "./tabs"; diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index eb1756ebc..1adac0602 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -52,7 +52,7 @@ public DocumentationGenerator( DocumentationSet = docSet; Context = docSet.Build; Resolver = docSet.LinkResolver; - HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem); + HtmlWriter = new HtmlWriter(DocumentationSet, _writeFileSystem, new DescriptionGenerator()); _documentationFileExporter = documentationExporter ?? new DocumentationFileExporter(docSet.Build.ReadFileSystem, _writeFileSystem, HtmlWriter, conversionCollector); diff --git a/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs b/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs index bc05217bb..65457a013 100644 --- a/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs +++ b/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs @@ -20,6 +20,9 @@ public class YamlFrontMatter [YamlMember(Alias = "title")] public string? Title { get; set; } + [YamlMember(Alias = "description")] + public string? Description { get; set; } + [YamlMember(Alias = "navigation_title")] public string? NavigationTitle { get; set; } diff --git a/src/Elastic.Markdown/Slices/DescriptionGenerator.cs b/src/Elastic.Markdown/Slices/DescriptionGenerator.cs new file mode 100644 index 000000000..35b740381 --- /dev/null +++ b/src/Elastic.Markdown/Slices/DescriptionGenerator.cs @@ -0,0 +1,124 @@ +// 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.Text; +using Elastic.Markdown.Myst.Substitution; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Elastic.Markdown.Slices; + +public interface IDescriptionGenerator +{ + string GenerateDescription(MarkdownDocument document); +} + + +public class DescriptionGenerator : IDescriptionGenerator +{ + private const int MaxLength = 150; + + public string GenerateDescription(MarkdownDocument document) + { + var description = new StringBuilder(); + foreach (var block in document.TakeWhile(_ => description.Length < MaxLength)) + { + // TODO: Add support for IncludeBlock + // This is needed when the first block is an IncludeBlock. + switch (block) + { + case ParagraphBlock paragraph: + { + ProcessParagraph(paragraph, description); + break; + } + case ListBlock listBlock: + { + ProcessListBlock(listBlock, description); + break; + } + } + } + + var result = description.ToString(); + // It can happen that the last parsed block is longer, hence the result is longer than maxLength + // Hence we need to shorten it. In this case it will be shorted to until the next space after `MaxLength` + if (result.Length > MaxLength) + { + var endIndex = result.IndexOf(' ', MaxLength - 1); + if (endIndex == -1) + endIndex = MaxLength; + result = string.Concat(result.AsSpan(0, endIndex + 1).Trim().TrimEnd('.'), "..."); + } + + return result; + } + + private static void ProcessParagraph(ParagraphBlock paragraph, StringBuilder description) + { + if (paragraph.Inline == null) + return; + + var paragraphText = GetInlineText(paragraph.Inline); + if (string.IsNullOrEmpty(paragraphText)) + return; + + _ = description.Append(paragraphText); + _ = description.Append(' '); + } + + private static void ProcessListBlock(ListBlock listBlock, StringBuilder description) + { + foreach (var item in listBlock) + { + if (item is not ListItemBlock listItem) + continue; + + foreach (var listItemBlock in listItem) + { + if (listItemBlock is not ParagraphBlock listItemParagraph || listItemParagraph.Inline == null) + continue; + + var paragraphText = GetInlineText(listItemParagraph.Inline); + _ = description.Append(paragraphText); + var lastChar = paragraphText[^1]; + if (lastChar is not '.' and not ',' and not '!' and not '?') + _ = description.Append(listItem == listBlock.LastChild ? ". " : ", "); + } + } + } + + private static string GetInlineText(ContainerInline inline) + { + var builder = new StringBuilder(); + foreach (var item in inline) + { + switch (item) + { + case SubstitutionLeaf subs: + _ = builder.Append(subs.Replacement); + break; + case LiteralInline literal: + _ = builder.Append(literal.Content.ToString()); + break; + case EmphasisInline emphasis: + _ = builder.Append(GetInlineText(emphasis)); + break; + case LinkInline link: + _ = builder.Append(GetInlineText(link)); + break; + case CodeInline code: + _ = builder.Append(code.Content); + break; + case LineBreakInline: + _ = builder.Append(' '); + break; + case ContainerInline container: + _ = builder.Append(GetInlineText(container)); + break; + } + } + return builder.ToString(); + } +} diff --git a/src/Elastic.Markdown/Slices/HtmlWriter.cs b/src/Elastic.Markdown/Slices/HtmlWriter.cs index 013da2231..deac619e0 100644 --- a/src/Elastic.Markdown/Slices/HtmlWriter.cs +++ b/src/Elastic.Markdown/Slices/HtmlWriter.cs @@ -63,7 +63,7 @@ private NavigationViewModel CreateNavigationModel(INavigation navigation) } } -public class HtmlWriter(DocumentationSet documentationSet, IFileSystem writeFileSystem, INavigationHtmlWriter? navigationHtmlWriter = null) +public class HtmlWriter(DocumentationSet documentationSet, IFileSystem writeFileSystem, IDescriptionGenerator descriptionGenerator, INavigationHtmlWriter? navigationHtmlWriter = null) { private DocumentationSet DocumentationSet { get; } = documentationSet; public INavigationHtmlWriter NavigationHtmlWriter { get; } = navigationHtmlWriter ?? new IsolatedBuildNavigationHtmlWriter(documentationSet); @@ -93,6 +93,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDocument var slice = Index.Create(new IndexViewModel { Title = markdown.Title ?? "[TITLE NOT SET]", + Description = markdown.YamlFrontMatter?.Description ?? descriptionGenerator.GenerateDescription(document), TitleRaw = markdown.TitleRaw ?? "[TITLE NOT SET]", MarkdownHtml = html, PageTocItems = [.. markdown.PageTableOfContent.Values], diff --git a/src/Elastic.Markdown/Slices/Index.cshtml b/src/Elastic.Markdown/Slices/Index.cshtml index 7d8d03a11..bb938baed 100644 --- a/src/Elastic.Markdown/Slices/Index.cshtml +++ b/src/Elastic.Markdown/Slices/Index.cshtml @@ -5,6 +5,7 @@ public LayoutViewModel LayoutModel => new() { Title = $"Elastic Documentation: {Model.Title}", + Description = Model.Description, PageTocItems = Model.PageTocItems.Where(i => i is { Level: 2 or 3 }).ToList(), CurrentDocument = Model.CurrentDocument, Previous = Model.PreviousDocument, diff --git a/src/Elastic.Markdown/Slices/Layout/_Head.cshtml b/src/Elastic.Markdown/Slices/Layout/_Head.cshtml index f11e9c891..0881b258a 100644 --- a/src/Elastic.Markdown/Slices/Layout/_Head.cshtml +++ b/src/Elastic.Markdown/Slices/Layout/_Head.cshtml @@ -1,13 +1,13 @@ @inherits RazorSlice @using Elastic.Markdown.Helpers + @Model.Title + @foreach (var fontFile in await FontPreloader.GetFontUrisAsync(@Model.UrlPathPrefix)) { } - - @Model.Title - + @if (Model.CanonicalBaseUrl is not null) { diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs index 684bd46e6..c6850808d 100644 --- a/src/Elastic.Markdown/Slices/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/_ViewModels.cs @@ -11,6 +11,7 @@ namespace Elastic.Markdown.Slices; public class IndexViewModel { public required string Title { get; init; } + public required string Description { get; init; } public required string TitleRaw { get; init; } public required string MarkdownHtml { get; init; } public required DocumentationGroup Tree { get; init; } @@ -36,6 +37,7 @@ public class LayoutViewModel /// the guids that no longer exist public static string CurrentNavigationId { get; } = Guid.NewGuid().ToString("N")[..8]; public string Title { get; set; } = "Elastic Documentation"; + public required string Description { get; init; } public required IReadOnlyCollection PageTocItems { get; init; } public required MarkdownFile CurrentDocument { get; init; } public required MarkdownFile? Previous { get; init; }