diff --git a/docs/syntax/applies.md b/docs/syntax/applies.md index 20f4e693a..a4f57cc8a 100644 --- a/docs/syntax/applies.md +++ b/docs/syntax/applies.md @@ -154,6 +154,34 @@ stack: ga 9.1 This will allow the yaml inside the `{applies-to}` directive to be fully highlighted. +## Inline Applies To + +Inline applies to can be placed anywhere using the following syntax + +```markdown +This can live inline {applies_to}`section: [version]` +``` + +An inline version example would be {applies_to}`stack: beta 9.1` this allows you to target elements more concretely visually. + +A common use case would be to place them on definition lists: + +Fruit {applies_to}`stack: preview 9.1` +: A sweet and fleshy product of a tree or other plant that contains seed and can be eaten as food. Common examples include apples, oranges, and bananas. Most fruits are rich in vitamins, minerals and fiber. + +Applies {preview}`9.1` +: A sweet and fleshy product of a tree or other plant that contains seed and can be eaten as food. Common examples include apples, oranges, and bananas. Most fruits are rich in vitamins, minerals and fiber. + + +A specialized `{preview}` role exist to quickly mark something as a technical preview. It takes a required version number +as argument. + +```markdown +Property {preview}`` +: definition body +``` + + ## Examples @@ -162,6 +190,7 @@ This will allow the yaml inside the `{applies-to}` directive to be fully highlig stack: ga 9.1 ``` + #### Stack with deployment ```yaml {applies_to} stack: ga 9.1 diff --git a/src/Elastic.Markdown/Assets/styles.css b/src/Elastic.Markdown/Assets/styles.css index 2583aea32..e339b9209 100644 --- a/src/Elastic.Markdown/Assets/styles.css +++ b/src/Elastic.Markdown/Assets/styles.css @@ -146,13 +146,19 @@ @apply font-sans; border-bottom: 1px solid var(--color-grey-20); padding-bottom: calc(var(--spacing) * 3); + font-variant: all-petite-caps; - .applies-to-label { - display: block; - font-size: 1.5em; - font-weight: var(--font-weight-extrabold); - padding-bottom: calc(var(--spacing) * 3); + .applicable-meta-discontinued { + color: var(--color-red-90); } + .applicable-meta-coming { + color: var(--color-blue-elastic-80); + } + .applicable-meta-technical-preview { + color: var(--color-yellow-80); + } + + .applicable-info { padding: calc(var(--spacing) * 0.5); padding-left: calc(var(--spacing) * 2); @@ -164,6 +170,52 @@ background-color: var(--color-white); border: 1px solid var(--color-grey-20); } + .applicable-version { + font-weight: bold; + margin-left: calc(var(--spacing) * 0.5); + font-variant: none; + font-size: 0.87em; + } + .applicable-lifecycle { + font-weight: bold; + } + } + .applies-inline { + @apply font-sans; + font-variant: all-petite-caps; + + .applicable-meta-discontinued { + color: var(--color-red-90); + } + .applicable-meta-coming { + color: var(--color-blue-elastic-80); + } + .applicable-meta-technical-preview { + color: var(--color-blue-elastic-80); + } + + .applicable-info { + padding: calc(var(--spacing) * 0.5); + padding-left: calc(var(--spacing) * 2); + padding-right: calc(var(--spacing) * 2); + margin-left: calc(var(--spacing) * 0.5); + margin-right: calc(var(--spacing) * 0.5); + display: inline-block; + font-size: 0.8em; + border-radius: 0.4em; + background-color: var(--color-white); + border: 1px solid var(--color-grey-20); + font-weight: normal; + } + .applicable-version { + font-weight: bold; + margin-left: calc(var(--spacing) * 0.5); + font-variant: none; + font-size: 0.87em; + } + .applicable-lifecycle { + font-weight: bold; + } } } diff --git a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs index d2f5e3314..1c125e0fa 100644 --- a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs +++ b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs @@ -12,6 +12,8 @@ namespace Elastic.Markdown.Diagnostics; public static class ProcessorDiagnosticExtensions { + private static string CreateExceptionMessage(string message, Exception? e) => message + (e != null ? Environment.NewLine + e : string.Empty); + public static void EmitError(this InlineProcessor processor, int line, int column, int length, string message) { var context = processor.GetContext(); @@ -55,7 +57,7 @@ public static void EmitError(this ParserContext context, string message, Excepti { Severity = Severity.Error, File = context.MarkdownSourcePath.FullName, - Message = message + (e != null ? Environment.NewLine + e : string.Empty), + Message = CreateExceptionMessage(message, e), }; context.Build.Collector.Channel.Write(d); } @@ -82,7 +84,7 @@ public static void EmitError(this BuildContext context, IFileInfo file, string m { Severity = Severity.Error, File = file.FullName, - Message = message + (e != null ? Environment.NewLine + e : string.Empty), + Message = CreateExceptionMessage(message, e), }; context.Collector.Channel.Write(d); } @@ -104,7 +106,7 @@ public static void EmitError(this DiagnosticsCollector collector, IFileInfo file { Severity = Severity.Error, File = file.FullName, - Message = message + (e != null ? Environment.NewLine + e : string.Empty), + Message = CreateExceptionMessage(message, e), }; collector.Channel.Write(d); } @@ -132,11 +134,12 @@ public static void EmitError(this IBlockExtension block, string message, Excepti Line = block.Line + 1, Column = block.Column, Length = block.OpeningLength + 5, - Message = message + (e != null ? Environment.NewLine + e : string.Empty), + Message = CreateExceptionMessage(message, e), }; block.Build.Collector.Channel.Write(d); } + public static void EmitWarning(this IBlockExtension block, string message) { if (block.SkipValidation) @@ -154,12 +157,10 @@ public static void EmitWarning(this IBlockExtension block, string message) block.Build.Collector.Channel.Write(d); } - private static void LinkDiagnostic(InlineProcessor processor, Severity severity, LinkInline inline, string message) + private static void LinkDiagnostic(InlineProcessor processor, Severity severity, Inline inline, int length, string message, Exception? e = null) { - var url = inline.Url; var line = inline.Line + 1; var column = inline.Column; - var length = url?.Length ?? 1; var context = processor.GetContext(); if (context.SkipValidation) @@ -170,18 +171,27 @@ private static void LinkDiagnostic(InlineProcessor processor, Severity severity, File = processor.GetContext().MarkdownSourcePath.FullName, Column = Math.Max(column, 1), Line = line, - Message = message, - Length = length + Message = CreateExceptionMessage(message, e), + Length = Math.Max(length, 1) }; context.Build.Collector.Channel.Write(d); } public static void EmitError(this InlineProcessor processor, LinkInline inline, string message) => - LinkDiagnostic(processor, Severity.Error, inline, message); + LinkDiagnostic(processor, Severity.Error, inline, inline.Url?.Length ?? 1, message); public static void EmitWarning(this InlineProcessor processor, LinkInline inline, string message) => - LinkDiagnostic(processor, Severity.Warning, inline, message); + LinkDiagnostic(processor, Severity.Warning, inline, inline.Url?.Length ?? 1, message); public static void EmitHint(this InlineProcessor processor, LinkInline inline, string message) => - LinkDiagnostic(processor, Severity.Hint, inline, message); + LinkDiagnostic(processor, Severity.Hint, inline, inline.Url?.Length ?? 1, message); + + public static void EmitError(this InlineProcessor processor, Inline inline, int length, string message, Exception? e = null) => + LinkDiagnostic(processor, Severity.Error, inline, length, message, e); + + public static void EmitWarning(this InlineProcessor processor, Inline inline, int length, string message) => + LinkDiagnostic(processor, Severity.Warning, inline, length, message); + + public static void EmitHint(this InlineProcessor processor, Inline inline, int length, string message) => + LinkDiagnostic(processor, Severity.Hint, inline, length, message); } diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs index d3522f666..204ba62c9 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs @@ -10,12 +10,6 @@ namespace Elastic.Markdown.Myst.CodeBlocks; -public class AppliesToDirective(BlockParser parser, ParserContext context) - : EnhancedCodeBlock(parser, context) -{ - public ApplicableTo? AppliesTo { get; set; } -} - public class EnhancedCodeBlock(BlockParser parser, ParserContext context) : FencedCodeBlock(parser), IBlockExtension { diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs index c4e2d8df6..ecc056b4c 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Myst.Comments; +using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Slices.Directives; using Markdig.Helpers; using Markdig.Renderers; @@ -228,10 +229,10 @@ protected override void Write(HtmlRenderer renderer, EnhancedCodeBlock block) private static void RenderAppliesToHtml(HtmlRenderer renderer, AppliesToDirective appliesToDirective) { var appliesTo = appliesToDirective.AppliesTo; - var slice2 = ApplicableTo.Create(appliesTo); + var slice = ApplicableToDirective.Create(appliesTo); if (appliesTo is null || appliesTo == FrontMatter.ApplicableTo.All) return; - var html = slice2.RenderAsync().GetAwaiter().GetResult(); + var html = slice.RenderAsync().GetAwaiter().GetResult(); _ = renderer.Write(html); } } diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs index 74f4b7466..6269e3459 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockParser.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Helpers; +using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.FrontMatter; using Markdig.Helpers; using Markdig.Parsers; diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesBlock.cs b/src/Elastic.Markdown/Myst/Directives/AppliesBlock.cs deleted file mode 100644 index a253dd471..000000000 --- a/src/Elastic.Markdown/Myst/Directives/AppliesBlock.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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; - -namespace Elastic.Markdown.Myst.Directives; - -public class AppliesBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) -{ - public override string Directive => "mermaid"; - - public override void FinalizeAndValidate(ParserContext context) => this.EmitWarning("{applies} is deprecated, please use the {apply} directive"); -} diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesToDirective.cs b/src/Elastic.Markdown/Myst/Directives/AppliesToDirective.cs new file mode 100644 index 000000000..b42574e0f --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/AppliesToDirective.cs @@ -0,0 +1,17 @@ +// 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.Myst.CodeBlocks; +using Elastic.Markdown.Myst.FrontMatter; +using Markdig.Parsers; + +namespace Elastic.Markdown.Myst.Directives; + + +public class AppliesToDirective(BlockParser parser, ParserContext context) + : EnhancedCodeBlock(parser, context), IApplicableToElement +{ + public ApplicableTo? AppliesTo { get; set; } +} + diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 3866931cf..ce9b9d13f 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -104,9 +104,6 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{literalinclude}") > 0) return new LiteralIncludeBlock(this, context); - if (info.IndexOf("{applies}") > 0) - return new AppliesBlock(this, context); - if (info.IndexOf("{settings}") > 0) return new SettingsBlock(this, context); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index ac991ee24..9d84b4675 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -4,8 +4,8 @@ using System.Diagnostics.CodeAnalysis; using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst.InlineParsers.Substitution; using Elastic.Markdown.Myst.Settings; -using Elastic.Markdown.Myst.Substitution; using Elastic.Markdown.Slices.Directives; using Markdig; using Markdig.Renderers; @@ -32,8 +32,6 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case MermaidBlock mermaidBlock: WriteMermaid(renderer, mermaidBlock); return; - case AppliesBlock: //deprecated scheduled for removal - return; case FigureBlock imageBlock: WriteFigure(renderer, imageBlock); return; diff --git a/src/Elastic.Markdown/Myst/FrontMatter/Applicability.cs b/src/Elastic.Markdown/Myst/FrontMatter/Applicability.cs index 05b520098..098acc75a 100644 --- a/src/Elastic.Markdown/Myst/FrontMatter/Applicability.cs +++ b/src/Elastic.Markdown/Myst/FrontMatter/Applicability.cs @@ -100,6 +100,22 @@ public record Applicability Version = AllVersions.Instance }; + + public string GetLifeCycleName() => + Lifecycle switch + { + ProductLifecycle.TechnicalPreview => "Technical Preview", + ProductLifecycle.Beta => "Beta", + ProductLifecycle.Development => "Development", + ProductLifecycle.Deprecated => "Deprecated", + ProductLifecycle.Coming => "Coming", + ProductLifecycle.Discontinued => "Discontinued", + ProductLifecycle.Unavailable => "Unavailable", + ProductLifecycle.GenerallyAvailable => "GA", + _ => throw new ArgumentOutOfRangeException(nameof(Lifecycle), Lifecycle, null) + }; + + public override string ToString() { if (this == GenerallyAvailable) diff --git a/src/Elastic.Markdown/Myst/FrontMatter/ApplicableTo.cs b/src/Elastic.Markdown/Myst/FrontMatter/ApplicableTo.cs index cfe57d0fa..a085ce6b3 100644 --- a/src/Elastic.Markdown/Myst/FrontMatter/ApplicableTo.cs +++ b/src/Elastic.Markdown/Myst/FrontMatter/ApplicableTo.cs @@ -28,6 +28,11 @@ public class WarningCollection : IEquatable, IReadOnlyCollect public int Count => _list.Count; } +public interface IApplicableToElement +{ + ApplicableTo? AppliesTo { get; } +} + [YamlSerializable] public record ApplicableTo { diff --git a/src/Elastic.Markdown/Myst/InlineParsers/LazySubstring.cs b/src/Elastic.Markdown/Myst/InlineParsers/LazySubstring.cs new file mode 100644 index 000000000..40a7e76f9 --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/LazySubstring.cs @@ -0,0 +1,42 @@ +// 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.Diagnostics; + +namespace Elastic.Markdown.Myst.InlineParsers; + +internal struct LazySubstring +{ + private string _text; + public int Offset; + public int Length; + + public LazySubstring(string text) + { + _text = text; + Offset = 0; + Length = text.Length; + } + + public LazySubstring(string text, int offset, int length) + { + Debug.Assert((ulong)offset + (ulong)length <= (ulong)text.Length, $"{offset}-{length} in {text}"); + _text = text; + Offset = offset; + Length = length; + } + + public readonly ReadOnlySpan AsSpan() => _text.AsSpan(Offset, Length); + + public override string ToString() + { + if (Offset != 0 || Length != _text.Length) + { + _text = _text.Substring(Offset, Length); + Offset = 0; + } + + return _text; + } +} diff --git a/src/Elastic.Markdown/Myst/InlineParsers/StringSliceExtensions.cs b/src/Elastic.Markdown/Myst/InlineParsers/StringSliceExtensions.cs new file mode 100644 index 000000000..142435d86 --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/StringSliceExtensions.cs @@ -0,0 +1,26 @@ +// 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.Runtime.CompilerServices; +using Markdig.Helpers; + +namespace Elastic.Markdown.Myst.InlineParsers; + +public static class StringSliceExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int CountAndSkipChar(this StringSlice slice, char matchChar) + { + var text = slice.Text; + var end = slice.End; + var current = slice.Start; + + while (current <= end && (uint)current < (uint)text.Length && text[current] == matchChar) + current++; + + var count = current - slice.Start; + slice.Start = current; + return count; + } +} diff --git a/src/Elastic.Markdown/Myst/Substitution/SubstitutionBuilderExtensions.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionBuilderExtensions.cs similarity index 94% rename from src/Elastic.Markdown/Myst/Substitution/SubstitutionBuilderExtensions.cs rename to src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionBuilderExtensions.cs index ed783df0f..c8c33fba3 100644 --- a/src/Elastic.Markdown/Myst/Substitution/SubstitutionBuilderExtensions.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionBuilderExtensions.cs @@ -1,10 +1,11 @@ // 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 Markdig; using Markdig.Renderers; -namespace Elastic.Markdown.Myst.Substitution; +namespace Elastic.Markdown.Myst.InlineParsers.Substitution; public static class SubstitutionBuilderExtensions { diff --git a/src/Elastic.Markdown/Myst/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs similarity index 74% rename from src/Elastic.Markdown/Myst/Substitution/SubstitutionParser.cs rename to src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 35f7d5b96..34c20295a 100644 --- a/src/Elastic.Markdown/Myst/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -1,9 +1,9 @@ // 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.Buffers; using System.Diagnostics; -using System.Runtime.CompilerServices; using Elastic.Markdown.Diagnostics; using Markdig.Helpers; using Markdig.Parsers; @@ -12,60 +12,7 @@ using Markdig.Syntax; using Markdig.Syntax.Inlines; -namespace Elastic.Markdown.Myst.Substitution; - -public static class StringSliceExtensions -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static int CountAndSkipChar(this StringSlice slice, char matchChar) - { - var text = slice.Text; - var end = slice.End; - var current = slice.Start; - - while (current <= end && (uint)current < (uint)text.Length && text[current] == matchChar) - current++; - - var count = current - slice.Start; - slice.Start = current; - return count; - } -} - -internal struct LazySubstring -{ - private string _text; - public int Offset; - public int Length; - - public LazySubstring(string text) - { - _text = text; - Offset = 0; - Length = text.Length; - } - - public LazySubstring(string text, int offset, int length) - { - Debug.Assert((ulong)offset + (ulong)length <= (ulong)text.Length, $"{offset}-{length} in {text}"); - _text = text; - Offset = offset; - Length = length; - } - - public readonly ReadOnlySpan AsSpan() => _text.AsSpan(Offset, Length); - - public override string ToString() - { - if (Offset != 0 || Length != _text.Length) - { - _text = _text.Substring(Offset, Length); - Offset = 0; - } - - return _text; - } -} +namespace Elastic.Markdown.Myst.InlineParsers.Substitution; [DebuggerDisplay("{GetType().Name} Line: {Line}, Found: {Found}, Replacement: {Replacement}")] public class SubstitutionLeaf(string content, bool found, string replacement) : CodeInline(content) diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 7e6356c60..24dfd6010 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -9,8 +9,10 @@ using Elastic.Markdown.Myst.Directives; using Elastic.Markdown.Myst.FrontMatter; using Elastic.Markdown.Myst.InlineParsers; +using Elastic.Markdown.Myst.InlineParsers.Substitution; using Elastic.Markdown.Myst.Renderers; -using Elastic.Markdown.Myst.Substitution; +using Elastic.Markdown.Myst.Roles; +using Elastic.Markdown.Myst.Roles.AppliesTo; using Markdig; using Markdig.Extensions.EmphasisExtras; using Markdig.Parsers; @@ -143,6 +145,7 @@ public MarkdownPipeline Pipeline .UseDiagnosticLinks() .UseHeadingsWithSlugs() .UseEmphasisExtras(EmphasisExtraOptions.Default) + .UseInlineAppliesTo() .UseSubstitution() .UseComments() .UseYamlFrontMatter() diff --git a/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRole.cs b/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRole.cs new file mode 100644 index 000000000..3f3da4411 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRole.cs @@ -0,0 +1,90 @@ +// 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.Diagnostics; +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Helpers; +using Elastic.Markdown.Myst.FrontMatter; +using Markdig; +using Markdig.Parsers; +using Markdig.Parsers.Inlines; +using Markdig.Renderers; +using Markdig.Renderers.Html.Inlines; + +namespace Elastic.Markdown.Myst.Roles.AppliesTo; + +[DebuggerDisplay("{GetType().Name} Line: {Line}, Role: {Role}, Content: {Content}")] +public class AppliesToRole : RoleLeaf, IApplicableToElement +{ + public AppliesToRole(string role, string content, InlineProcessor parserContext) : base(role, content) => + AppliesTo = ParseApplicableTo(content, parserContext); + + public ApplicableTo? AppliesTo { get; } + + private ApplicableTo? ParseApplicableTo(string yaml, InlineProcessor processor) + { + try + { + var applicableTo = YamlSerialization.Deserialize(yaml); + if (applicableTo.Warnings is null) + return applicableTo; + foreach (var warning in applicableTo.Warnings) + processor.EmitWarning(this, Role.Length + yaml.Length, warning); + applicableTo.Warnings = null; + return applicableTo; + } + catch (Exception e) + { + processor.EmitError(this, Role.Length + yaml.Length, $"Unable to parse applies_to role: {{{Role}}}{yaml}", e); + } + + return null; + } +} + +public class AppliesToRoleParser : RoleParser +{ + protected override AppliesToRole CreateRole(string role, string content, InlineProcessor parserContext) => + new(role, content, parserContext); + + protected override bool Matches(ReadOnlySpan role) => role is "{applies_to}"; +} +public class PreviewRoleParser : RoleParser +{ + protected override AppliesToRole CreateRole(string role, string content, InlineProcessor parserContext) + { + content = SemVersion.TryParse(content, out _) + ? $"product: preview {content}" + : SemVersion.TryParse(content + ".0", out var version) + ? $"product: preview {version}" + : "product: preview"; + return new AppliesToRole(role, content, parserContext); + } + + protected override bool Matches(ReadOnlySpan role) => role is "{preview}"; +} + +public static class InlineAppliesToExtensions +{ + public static MarkdownPipelineBuilder UseInlineAppliesTo(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} + +public class InlineAppliesToExtension : IMarkdownExtension +{ + public void Setup(MarkdownPipelineBuilder pipeline) + { + _ = pipeline.InlineParsers.InsertBefore(new AppliesToRoleParser()); + _ = pipeline.InlineParsers.InsertAfter(new PreviewRoleParser()); + } + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) => + renderer.ObjectRenderers.InsertBefore(new AppliesToRoleHtmlRenderer()); +} + + + diff --git a/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRoleRenderer.cs b/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRoleRenderer.cs new file mode 100644 index 000000000..8608cf51c --- /dev/null +++ b/src/Elastic.Markdown/Myst/Roles/AppliesTo/AppliesToRoleRenderer.cs @@ -0,0 +1,25 @@ +// 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.Diagnostics.CodeAnalysis; +using Elastic.Markdown.Slices.Roles; +using Markdig.Renderers; +using Markdig.Renderers.Html; +using RazorSlices; + +namespace Elastic.Markdown.Myst.Roles.AppliesTo; + +public class AppliesToRoleHtmlRenderer : HtmlObjectRenderer +{ + [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")] + protected override void Write(HtmlRenderer renderer, AppliesToRole role) + { + var appliesTo = role.AppliesTo; + var slice = ApplicableToRole.Create(appliesTo); + if (appliesTo is null || appliesTo == FrontMatter.ApplicableTo.All) + return; + var html = slice.RenderAsync().GetAwaiter().GetResult(); + _ = renderer.Write(html); + } +} diff --git a/src/Elastic.Markdown/Myst/Roles/RoleParser.cs b/src/Elastic.Markdown/Myst/Roles/RoleParser.cs new file mode 100644 index 000000000..54e5fe4c2 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Roles/RoleParser.cs @@ -0,0 +1,117 @@ +// 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.Buffers; +using System.Diagnostics; +using Elastic.Markdown.Myst.InlineParsers; +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Elastic.Markdown.Myst.Roles; + +[DebuggerDisplay("{GetType().Name} Line: {Line}, Role: {Role}, Content: {Content}")] +public abstract class RoleLeaf(string role, string content) : CodeInline(content) +{ + public string Role => role; +} + +public abstract class RoleParser : InlineParser + where TRole : RoleLeaf +{ + protected RoleParser() => OpeningCharacters = ['{']; + + private readonly SearchValues _values = SearchValues.Create(['\r', '\n', ' ', '\t', '}']); + + protected abstract TRole CreateRole(string role, string content, InlineProcessor parserContext); + + protected abstract bool Matches(ReadOnlySpan role); + + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + var match = slice.CurrentChar; + + if (processor.Context is not ParserContext) + return false; + + Debug.Assert(match is not ('\r' or '\n')); + + // Match the opened sticks + var openSticks = slice.CountAndSkipChar(match); + if (openSticks > 1) + return false; + + var span = slice.AsSpan(); + + var i = span.IndexOfAny(_values); + + // We got to the end of the input before seeing the match character. + if ((uint)i >= (uint)span.Length) + return false; + + var closeSticks = 0; + while ((uint)i < (uint)span.Length && span[i] == '}') + { + closeSticks++; + i++; + } + if (closeSticks > 1) + return false; + + var roleContent = slice.AsSpan()[..i]; + if (!Matches(roleContent)) + return false; + + // {role} has to be followed by `content` + if (span[i] != '`') + return false; + if (span.Length == i - 1) + return false; + + var startContent = i; + i = span[(i + 1)..].IndexOfAny(['`']); + if ((uint)i >= (uint)span.Length) + return false; + + var closeBackTicks = 0; + while ((uint)i < (uint)span.Length && span[i] == '`') + { + closeBackTicks++; + i++; + } + if (closeBackTicks > 1) + return false; + + var contentSpan = span[startContent..(startContent + i + 2)]; + + var startPosition = slice.Start; + slice.Start = startPosition + roleContent.Length + contentSpan.Length; + + // We've already skipped the opening sticks. Account for that here. + startPosition -= openSticks; + startPosition = Math.Max(startPosition, 0); + + var start = processor.GetSourcePosition(startPosition, out var line, out var column); + var end = processor.GetSourcePosition(slice.Start); + var sourceSpan = new SourceSpan(start, end); + + var leaf = CreateRole(roleContent.ToString(), contentSpan.Trim('`').ToString(), processor); + leaf.Delimiter = '{'; + leaf.Span = sourceSpan; + leaf.Line = line; + leaf.Column = column; + leaf.DelimiterCount = openSticks; + + if (processor.TrackTrivia) + { + // startPosition and slice.Start include the opening/closing sticks. + leaf.ContentWithTrivia = + new StringSlice(slice.Text, startPosition + openSticks, slice.Start - openSticks - 1); + } + + processor.Inline = leaf; + return true; + } +} diff --git a/src/Elastic.Markdown/Slices/Components/ApplicableToComponent.cshtml b/src/Elastic.Markdown/Slices/Components/ApplicableToComponent.cshtml new file mode 100644 index 000000000..92cae4c35 --- /dev/null +++ b/src/Elastic.Markdown/Slices/Components/ApplicableToComponent.cshtml @@ -0,0 +1,83 @@ +@using Elastic.Markdown.Myst.FrontMatter +@inherits RazorSlice + +@{ + var appliesTo = Model.AppliesTo; +} + +@if (appliesTo.Stack is not null) +{ + @RenderProduct("Elastic Stack", appliesTo.Stack) +} +@if (appliesTo.Deployment is not null) +{ + if (appliesTo.Deployment.Ece is not null) + { + @RenderProduct("ECE", appliesTo.Deployment.Ece) + } + if (appliesTo.Deployment.Eck is not null) + { + @RenderProduct("ECK", appliesTo.Deployment.Eck) + } + if (appliesTo.Deployment.Ess is not null) + { + @RenderProduct("Elastic Cloud Hosted", appliesTo.Deployment.Ess) + } + if (appliesTo.Deployment.Self is not null) + { + @RenderProduct("Self Managed", appliesTo.Deployment.Self) + } +} +@if (appliesTo.Serverless is not null) +{ + if (appliesTo.Serverless.AllProjects is not null) + { + @RenderProduct("Serverless", appliesTo.Serverless.AllProjects) + } + else + { + if (appliesTo.Serverless.Elasticsearch is not null) + { + @RenderProduct("Serverless Elasticsearch", appliesTo.Serverless.Elasticsearch) + } + if (appliesTo.Serverless.Observability is not null) + { + @RenderProduct("Serverless Observability", appliesTo.Serverless.Observability) + } + if (appliesTo.Serverless.Security is not null) + { + @RenderProduct("Serverless Security", appliesTo.Serverless.Security) + } + + } + +} +@if (appliesTo.Product is not null) +{ + @RenderProduct("", appliesTo.Product) +} + +@functions { + + private IHtmlContent RenderProduct(string name, AppliesCollection applications) + { + foreach (var applicability in applications) + { + var lifecycleClass = applicability.GetLifeCycleName().ToLowerInvariant().Replace(" ", "-"); + + @name + + @if (applicability.Lifecycle != ProductLifecycle.GenerallyAvailable) + { + @applicability.GetLifeCycleName() + } + @if (applicability.Version is not null and not AllVersions) + { + @applicability.Version + } + + + } + return HtmlString.Empty; + } +} \ No newline at end of file diff --git a/src/Elastic.Markdown/Slices/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Slices/Components/ApplicableToViewModel.cs new file mode 100644 index 000000000..3985b56e4 --- /dev/null +++ b/src/Elastic.Markdown/Slices/Components/ApplicableToViewModel.cs @@ -0,0 +1,13 @@ +// 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.Myst.FrontMatter; + +namespace Elastic.Markdown.Slices.Components; + +public class ApplicableToViewModel +{ + public required bool Inline { get; init; } + public required ApplicableTo AppliesTo { get; init; } +} diff --git a/src/Elastic.Markdown/Slices/DescriptionGenerator.cs b/src/Elastic.Markdown/Slices/DescriptionGenerator.cs index 35b740381..701d618bf 100644 --- a/src/Elastic.Markdown/Slices/DescriptionGenerator.cs +++ b/src/Elastic.Markdown/Slices/DescriptionGenerator.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information using System.Text; -using Elastic.Markdown.Myst.Substitution; +using Elastic.Markdown.Myst.InlineParsers.Substitution; using Markdig.Syntax; using Markdig.Syntax.Inlines; diff --git a/src/Elastic.Markdown/Slices/Directives/ApplicableTo.cshtml b/src/Elastic.Markdown/Slices/Directives/ApplicableTo.cshtml deleted file mode 100644 index 735a0f12f..000000000 --- a/src/Elastic.Markdown/Slices/Directives/ApplicableTo.cshtml +++ /dev/null @@ -1,103 +0,0 @@ -@using Elastic.Markdown.Myst.FrontMatter -@inherits RazorSlice -

- - @if (Model.Stack is not null) - { - @RenderProduct("Elastic Stack", Model.Stack) - } - @if (Model.Deployment is not null) - { - if (Model.Deployment.Ece is not null) - { - @RenderProduct("ECE", Model.Deployment.Ece) - } - if (Model.Deployment.Eck is not null) - { - @RenderProduct("ECK", Model.Deployment.Eck) - } - if (Model.Deployment.Ess is not null) - { - @RenderProduct("Elastic Cloud Hosted", Model.Deployment.Ess) - } - if (Model.Deployment.Self is not null) - { - @RenderProduct("Self Managed", Model.Deployment.Self) - } - } - @if (Model.Serverless is not null) - { - if (Model.Serverless.AllProjects is not null) - { - @RenderProduct("Serverless", Model.Serverless.AllProjects) - } - else - { - if (Model.Serverless.Elasticsearch is not null) - { - @RenderProduct("Serverless Elasticsearch", Model.Serverless.Elasticsearch) - } - if (Model.Serverless.Observability is not null) - { - @RenderProduct("Serverless Observability", Model.Serverless.Observability) - } - if (Model.Serverless.Security is not null) - { - @RenderProduct("Serverless Security", Model.Serverless.Security) - } - - } - - } - @if (Model.Product is not null) - { - @RenderProduct("", Model.Product) - } -

- -@functions { - - private static string GetLifeCycleName(ProductLifecycle cycle) - { - switch (cycle) - { - case ProductLifecycle.TechnicalPreview: - return "Technical Preview"; - case ProductLifecycle.Beta: - return "Beta"; - case ProductLifecycle.Development: - return "Development"; - case ProductLifecycle.Deprecated: - return "Deprecated"; - case ProductLifecycle.Coming: - return "Coming"; - case ProductLifecycle.Discontinued: - return "Discontinued"; - case ProductLifecycle.Unavailable: - return "Unavailable"; - case ProductLifecycle.GenerallyAvailable: - return "GA"; - default: - throw new ArgumentOutOfRangeException(nameof(cycle), cycle, null); - } - } - - private IHtmlContent RenderProduct(string name, AppliesCollection applications) - { - foreach (var applicability in applications) - { - - @name - @if (applicability.Lifecycle != ProductLifecycle.GenerallyAvailable) - { - @GetLifeCycleName(applicability.Lifecycle) - } - @if (applicability.Version is not null and not AllVersions) - { - (@applicability.Version) - } - - } - return HtmlString.Empty; - } -} \ No newline at end of file diff --git a/src/Elastic.Markdown/Slices/Directives/ApplicableToDirective.cshtml b/src/Elastic.Markdown/Slices/Directives/ApplicableToDirective.cshtml new file mode 100644 index 000000000..73bfec8aa --- /dev/null +++ b/src/Elastic.Markdown/Slices/Directives/ApplicableToDirective.cshtml @@ -0,0 +1,11 @@ +@using Elastic.Markdown.Myst.FrontMatter +@using Elastic.Markdown.Slices.Components +@inherits RazorSlice + +

+@await RenderPartialAsync(ApplicableToComponent.Create(new ApplicableToViewModel +{ + AppliesTo = Model, + Inline = false +})) +

\ No newline at end of file diff --git a/src/Elastic.Markdown/Slices/HtmlWriter.cs b/src/Elastic.Markdown/Slices/HtmlWriter.cs index 508a2f2f1..0d6fbd437 100644 --- a/src/Elastic.Markdown/Slices/HtmlWriter.cs +++ b/src/Elastic.Markdown/Slices/HtmlWriter.cs @@ -121,7 +121,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDocument NextDocument = next, NavigationHtml = navigationHtml, UrlPathPrefix = markdown.UrlPathPrefix, - Applies = markdown.YamlFrontMatter?.AppliesTo, + AppliesTo = markdown.YamlFrontMatter?.AppliesTo, GithubEditUrl = editUrl, AllowIndexing = DocumentationSet.Build.AllowIndexing && !markdown.Hidden, CanonicalBaseUrl = DocumentationSet.Build.CanonicalBaseUrl, diff --git a/src/Elastic.Markdown/Slices/Index.cshtml b/src/Elastic.Markdown/Slices/Index.cshtml index aca7569ff..3259b394c 100644 --- a/src/Elastic.Markdown/Slices/Index.cshtml +++ b/src/Elastic.Markdown/Slices/Index.cshtml @@ -1,3 +1,4 @@ +@using Elastic.Markdown.Slices.Components @using Markdig @inherits RazorSliceHttpResult @implements IUsesLayout @@ -25,9 +26,15 @@
@* This way it's correctly rendered as

text

instead of

text

*@ @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) - @if (Model.Applies is not null) + @if (Model.AppliesTo is not null) { - await RenderPartialAsync(ApplicableTo.Create(Model.Applies)); +

+ @await RenderPartialAsync(ApplicableToComponent.Create(new ApplicableToViewModel + { + AppliesTo = Model.AppliesTo, + Inline = false + })) +

} @(new HtmlString(Model.MarkdownHtml))
diff --git a/src/Elastic.Markdown/Slices/Roles/ApplicableToRole.cshtml b/src/Elastic.Markdown/Slices/Roles/ApplicableToRole.cshtml new file mode 100644 index 000000000..c553d0f31 --- /dev/null +++ b/src/Elastic.Markdown/Slices/Roles/ApplicableToRole.cshtml @@ -0,0 +1,11 @@ +@using Elastic.Markdown.Myst.FrontMatter +@using Elastic.Markdown.Slices.Components +@inherits RazorSlice + + +@await RenderPartialAsync(ApplicableToComponent.Create(new ApplicableToViewModel +{ + AppliesTo = Model, + Inline = true +})) + \ No newline at end of file diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs index 75d294b49..d2c61a1ff 100644 --- a/src/Elastic.Markdown/Slices/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/_ViewModels.cs @@ -27,7 +27,7 @@ public class IndexViewModel public required string? UrlPathPrefix { get; init; } public required string? GithubEditUrl { get; init; } public required string? ReportIssueUrl { get; init; } - public required ApplicableTo? Applies { get; init; } + public required ApplicableTo? AppliesTo { get; init; } public required bool AllowIndexing { get; init; } public required Uri? CanonicalBaseUrl { get; init; } diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs index a4d087454..947a58ae0 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information using Elastic.Markdown.Myst.CodeBlocks; -using Elastic.Markdown.Myst.Substitution; +using Elastic.Markdown.Myst.InlineParsers.Substitution; using FluentAssertions; namespace Elastic.Markdown.Tests.Inline; diff --git a/tests/authoring/Applicability/AppliesToDirective.fs b/tests/authoring/Applicability/AppliesToDirective.fs index 2d4e76296..c062ed584 100644 --- a/tests/authoring/Applicability/AppliesToDirective.fs +++ b/tests/authoring/Applicability/AppliesToDirective.fs @@ -4,6 +4,7 @@ module ``product availability``.``yaml directive`` +open Elastic.Markdown.Myst.Directives open Elastic.Markdown.Myst.FrontMatter open authoring open authoring.MarkdownDocumentAssertions diff --git a/tests/authoring/Framework/MarkdownDocumentAssertions.fs b/tests/authoring/Framework/MarkdownDocumentAssertions.fs index d5be69881..1c54fb989 100644 --- a/tests/authoring/Framework/MarkdownDocumentAssertions.fs +++ b/tests/authoring/Framework/MarkdownDocumentAssertions.fs @@ -40,8 +40,10 @@ module MarkdownDocumentAssertions = [] - let appliesToDirective (expectedAvailability: ApplicableTo) (actual: AppliesToDirective array) = + let appliesToDirective<'element when 'element :> IApplicableToElement> + (expectedAvailability: ApplicableTo) (actual: 'element array) = let actual = actual |> Array.tryHead + match actual with | Some d -> let apply = d.AppliesTo diff --git a/tests/authoring/Inline/AppliesToRole.fs b/tests/authoring/Inline/AppliesToRole.fs new file mode 100644 index 000000000..7778ccf5b --- /dev/null +++ b/tests/authoring/Inline/AppliesToRole.fs @@ -0,0 +1,75 @@ +// 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 elements``.``applies_to role`` + +open Elastic.Markdown.Myst.FrontMatter +open Elastic.Markdown.Myst.Roles.AppliesTo +open Swensen.Unquote +open Xunit +open authoring +open authoring.MarkdownDocumentAssertions + +type ``parses inline {applies_to} role`` () = + static let markdown = Setup.Markdown """ + +This is an inline {applies_to}`stack: preview 9.1` element. +""" + + [] + let ``parses to AppliesDirective`` () = + let directives = markdown |> converts "index.md" |> parses + test <@ directives.Length = 1 @> + directives |> appliesToDirective (ApplicableTo( + Stack=AppliesCollection.op_Explicit "preview 9.1.0" + )) + + [] + let ``validate HTML: generates link and alt attr`` () = + markdown |> convertsToHtml """ +

This is an inline + + + Elastic Stack + + Technical Preview + 9.1.0 + + + + element. +

+ """ + + +type ``parses nested ess moniker`` () = + static let markdown = Setup.Markdown """ + +This is an inline {applies_to}`ess: preview 9.1` element. +""" + + [] + let ``parses to AppliesDirective`` () = + let directives = markdown |> converts "index.md" |> parses + test <@ directives.Length = 1 @> + directives |> appliesToDirective (ApplicableTo( + Deployment=DeploymentApplicability( + Ess=AppliesCollection.op_Explicit "preview 9.1.0" + ) + )) + +type ``parses {preview} shortcut`` () = + static let markdown = Setup.Markdown """ + +This is an inline {preview}`9.1` element. +""" + + [] + let ``parses to AppliesDirective`` () = + let directives = markdown |> converts "index.md" |> parses + test <@ directives.Length = 1 @> + directives |> appliesToDirective (ApplicableTo( + Product=AppliesCollection.op_Explicit "preview 9.1.0" + )) + diff --git a/tests/authoring/Inline/InlineImages.fs b/tests/authoring/Inline/InlineAppliesTo.fs similarity index 100% rename from tests/authoring/Inline/InlineImages.fs rename to tests/authoring/Inline/InlineAppliesTo.fs diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index 34ef73f6e..665f6e33f 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -43,7 +43,8 @@ - + +