diff --git a/docs-builder.sln b/docs-builder.sln index 1b00ed9a2..4f2a8e11a 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Markdown", "src\Elastic.Markdown\Elastic.Markdown.csproj", "{4D198E25-C211-41DC-9E84-B15E89BD7048}" EndProject diff --git a/docs/syntax/applies.md b/docs/syntax/applies.md index e5fc267eb..f82cb519b 100644 --- a/docs/syntax/applies.md +++ b/docs/syntax/applies.md @@ -1,40 +1,56 @@ --- -applies: - stack: ga 8.1 - serverless: tech-preview - hosted: beta 8.1.1 - eck: beta 3.0.2 - ece: unavailable +applies_to: + stack: ga 9.1 + deployment: + eck: ga 9.0 + ess: beta 9.1 + ece: discontinued 9.2.0 + self: unavailable 9.3.0 + serverless: + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 + product: coming 9.5, discontinued 9.7 --- # Applies to +Allows you to annotate a page or section's applicability. -Using yaml frontmatter pages can explicitly indicate to each deployment targets availability and lifecycle status - +### Syntax -```yaml -applies: - stack: ga 8.1 - serverless: tech-preview - hosted: beta 8.1.1 - eck: beta 3.0.2 - ece: unavailable ``` + [version], [version] +``` + +Taking a mandatory [life-cycle](#life-cycle) with an optional version. -Its syntax is +#### Life cycle: + * `preview` + * `beta` + * `development` + * `deprecated` + * `coming` + * `discontinued` + * `unavailable` + * `ga` + +#### Version + +Can be in either `major.minor` or `major.minor.patch` format + +#### Examples ``` - : [version] +coming 9.5, discontinued 9.7 +discontinued 9.2.0 +all ``` -Where version is optional. - `all` and empty string mean generally available for all active versions ```yaml applies: - stack: serverless: all ``` @@ -46,19 +62,134 @@ applies: serverless: beta ``` -Are equivalent, note `all` just means we won't be rendering the version portion in the html. +Both are equivalent, note `all` just means we won't be rendering the version portion in the html. -## This section has its own applies annotations [#sections] +## Structured model -:::{applies} -:serverless: unavailable -::: +![Applies To Model](img/applies.png) + +The above model is projected to the following structured yaml. + +```yaml +--- +applies_to: + stack: + deployment: + eck: + ess: + ece: + self: + serverless: + security: + elasticsearch: + observability: + product: +--- +``` +This allows you to annotate various facets as defined in [](../migration/versioning.md) + +## Page annotations + +Using yaml frontmatter pages can explicitly indicate to each deployment targets availability and lifecycle status + + +```yaml +--- +applies_to: + stack: ga 9.1 + deployment: + eck: ga 9.0 + ess: beta 9.1 + ece: discontinued 9.2.0 + self: unavailable 9.3.0 + serverless: + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 + product: coming 9.5, discontinued 9.7 +--- +``` + + +## Section annotation [#sections] + +```yaml {applies_to} +stack: ga 9.1 +deployment: + eck: ga 9.0 + ess: beta 9.1 + ece: discontinued 9.2.0 + self: unavailable 9.3.0 +serverless: + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 +product: coming 9.5, discontinued 9.7 +``` + +A header may be followed by an `{applies_to}` directive which will contextualize the applicability +of the section further. :::{note} -the `{applies}` directive **MUST** be preceded by a heading. +the `{applies_to}` directive **MUST** be preceded by a heading directly. ::: -This section describes a feature that's unavailable in `stack` and `ga` in all cloud products -however its tech preview on `serverless` since it overrides what `cloud` specified. +Note that this directive needs triple backticks since its content is literal. See also [](index.md#literal-directives) + +````markdown +```{applies_to} +stack: ga 9.1 +``` +```` + +In order to play even better with markdown editors the following is also supported: + +````markdown +```yaml {applies_to} +stack: ga 9.1 +``` +```` + +This will allow the yaml inside the `{applies-to}` directive to be fully highlighted. + + +## Examples + +#### Stack only +```yaml {applies_to} +stack: ga 9.1 +``` + +#### Stack with deployment +```yaml {applies_to} +stack: ga 9.1 +deployment: + eck: ga 9.0 + ess: beta 9.1 +``` + +#### Deployment only +```yaml {applies_to} +deployment: + ece: discontinued 9.2.0 + self: unavailable 9.3.0 +``` + +#### Serverless only +```yaml {applies_to} +serverless: ga 9.0.0 +``` + +#### Serverless with project differences +```yaml {applies_to} +serverless: + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 +``` +#### Stack with product +```yaml {applies_to} +stack: ga 9.1 +``` diff --git a/docs/syntax/img/applies.png b/docs/syntax/img/applies.png new file mode 100644 index 000000000..170d00ba6 Binary files /dev/null and b/docs/syntax/img/applies.png differ diff --git a/docs/syntax/index.md b/docs/syntax/index.md index e481c8735..32acb8d79 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -29,6 +29,8 @@ Nested content that will be parsed as markdown Defining directives with `:::` allows the nested markdown syntax to be highlighted properly by editors and web viewers. + + ### Nesting Directives Increase the number of leading semicolons to include nested directives. @@ -46,6 +48,13 @@ Content displayed in the note admonition ## Literal directives +All directive are indicated with semicolons except literal blocks. For these you need to use triple backticks. + +* [Code blocks](code.md) +* [{applies-to} blocks](applies.md) + +Since their contents **should not** be parsed as markdown they use backticks. This also ensures maximum interopability with existing markdown editors and previews. + Many Markdown editors support syntax highlighting for embedded code blocks. For compatibility with this feature, use triple backticks instead of triple colons for content that needs to be displayed literally: ````markdown diff --git a/docs/versions/content-patterns.md b/docs/versions/content-patterns.md index a0bbb08cd..1ddd2e932 100644 --- a/docs/versions/content-patterns.md +++ b/docs/versions/content-patterns.md @@ -31,12 +31,13 @@ See [Versions and lifecycle states](/versions/index.md#versions-and-lifecycle-st ## Section/heading-level `applies` tags -:::{applies} -:ece: all -:hosted: all -:eck: all -:stack: all -::: +```yaml {applies_to} +stack: ga 9.1 +deployment: + eck: ga 9.0 + ece: discontinued 9.2.0 + self: unavailable 9.3.0 +``` *see [`applies`](/syntax/applies.md#sections)* diff --git a/src/Elastic.Markdown.Refactor/Elastic.Markdown.Refactor.csproj b/src/Elastic.Markdown.Refactor/Elastic.Markdown.Refactor.csproj index c9bcb3d5f..a42588b61 100644 --- a/src/Elastic.Markdown.Refactor/Elastic.Markdown.Refactor.csproj +++ b/src/Elastic.Markdown.Refactor/Elastic.Markdown.Refactor.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/src/Elastic.Markdown/Assets/styles.css b/src/Elastic.Markdown/Assets/styles.css index e8d68c97d..2ce249099 100644 --- a/src/Elastic.Markdown/Assets/styles.css +++ b/src/Elastic.Markdown/Assets/styles.css @@ -86,6 +86,30 @@ .content-container { @apply w-full max-w-[80ch]; } + + .applies { + border-bottom: 1px solid var(--color-gray-300); + padding-bottom: calc(var(--spacing) * 3); + font-family: "Mier B", "Inter", sans-serif; + + .applies-to-label { + display: block; + font-size: 1.5em; + font-weight: var(--font-weight-extrabold); + padding-bottom: calc(var(--spacing) * 3); + } + .applicable-info { + padding: calc(var(--spacing) * 0.5); + padding-left: calc(var(--spacing) * 2); + padding-right: calc(var(--spacing) * 2); + margin: 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-gray-300); + } + } } * { diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj index 15b337c15..c4012a4a0 100644 --- a/src/Elastic.Markdown/Elastic.Markdown.csproj +++ b/src/Elastic.Markdown/Elastic.Markdown.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs index b15a4b3cc..4433efaee 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlock.cs @@ -4,11 +4,18 @@ using System.IO.Abstractions; using Elastic.Markdown.Myst.Directives; +using Elastic.Markdown.Myst.FrontMatter; using Markdig.Parsers; using Markdig.Syntax; 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 04315ebbc..940081aa1 100644 --- a/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/CodeBlocks/EnhancedCodeBlockHtmlRenderer.cs @@ -108,6 +108,12 @@ private static int CountIndentation(StringSlice slice) protected override void Write(HtmlRenderer renderer, EnhancedCodeBlock block) { + if (block is AppliesToDirective appliesToDirective) + { + RenderAppliesToHtml(renderer, appliesToDirective); + return; + } + var callOuts = block.UniqueCallOuts; var slice = Code.Create(new CodeViewModel @@ -184,4 +190,14 @@ protected override void Write(HtmlRenderer renderer, EnhancedCodeBlock block) renderer.WriteLine(""); } } + + private static void RenderAppliesToHtml(HtmlRenderer renderer, AppliesToDirective appliesToDirective) + { + var appliesTo = appliesToDirective.AppliesTo; + var slice2 = ApplicableTo.Create(appliesTo); + if (appliesTo is null || appliesTo == FrontMatter.ApplicableTo.All) + return; + var html = slice2.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 6857fa991..21474e935 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.FrontMatter; using Markdig.Helpers; using Markdig.Parsers; using Markdig.Syntax; @@ -30,7 +31,10 @@ protected override EnhancedCodeBlock CreateFencedBlock(BlockProcessor processor) if (processor.Context is not ParserContext context) throw new Exception("Expected parser context to be of type ParserContext"); - var codeBlock = new EnhancedCodeBlock(this, context) { IndentCount = processor.Indent }; + var lineSpan = processor.Line.AsSpan(); + var codeBlock = lineSpan.IndexOf("{applies_to}") > -1 + ? new AppliesToDirective(this, context) { IndentCount = processor.Indent } + : new EnhancedCodeBlock(this, context) { IndentCount = processor.Indent }; if (processor.TrackTrivia) { @@ -92,8 +96,37 @@ public override bool Close(BlockProcessor processor, Block block) codeBlock.EmitWarning($"Unknown language: {codeBlock.Language}"); var lines = codeBlock.Lines; - var callOutIndex = 0; + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (lines.Lines is null) + return base.Close(processor, block); + + if (codeBlock is not AppliesToDirective appliesToDirective) + ProcessCallOuts(lines, language, codeBlock, context); + else + ProcessAppliesToDirective(appliesToDirective, lines, context); + + return base.Close(processor, block); + } + + private static void ProcessAppliesToDirective(AppliesToDirective appliesToDirective, StringLineGroup lines, ParserContext context) + { + var yaml = lines.ToSlice().AsSpan().ToString(); + + try + { + var applicableTo = YamlSerialization.Deserialize(yaml); + appliesToDirective.AppliesTo = applicableTo; + } + catch (Exception e) + { + appliesToDirective.EmitError($"Unable to parse applies_to directive: {yaml}", e); + } + } + private static void ProcessCallOuts(StringLineGroup lines, string language, EnhancedCodeBlock codeBlock, + ParserContext context) + { + var callOutIndex = 0; var originatingLine = 0; for (var index = 0; index < lines.Lines.Length; index++) { @@ -128,6 +161,7 @@ public override bool Close(BlockProcessor processor, Block block) EnumerateAnnotations(matchClassicCallout, ref span, ref callOutIndex, originatingLine, false) ); } + // only support magic callouts for smaller line lengths if (callOuts.Count == 0 && span.Length < 200) { @@ -136,13 +170,13 @@ public override bool Close(BlockProcessor processor, Block block) EnumerateAnnotations(matchInline, ref span, ref callOutIndex, originatingLine, true) ); } + codeBlock.CallOuts.AddRange(callOuts); } //update string slices to ignore call outs if (codeBlock.CallOuts.Count > 0) { - var callouts = codeBlock.CallOuts.Aggregate(new Dictionary(), (acc, curr) => { if (acc.TryAdd(curr.Line, curr)) @@ -158,7 +192,6 @@ public override bool Close(BlockProcessor processor, Block block) var newSpan = line.Slice.AsSpan()[..callout.SliceStart]; var s = new StringSlice(newSpan.ToString()); lines.Lines[callout.Line - 1] = new StringLine(ref s); - } } @@ -169,8 +202,6 @@ public override bool Close(BlockProcessor processor, Block block) if (inlineAnnotations > 0) codeBlock.InlineAnnotations = true; - - return base.Close(processor, block); } private static List EnumerateAnnotations(Regex.ValueMatchEnumerator matches, @@ -232,6 +263,7 @@ private static List ParseClassicCallOuts(ValueMatch match, ref ReadOnly if (span[i] == '<') allStartIndices.Add(i); } + var callOuts = new List(); foreach (var individualStartIndex in allStartIndices) { @@ -250,6 +282,7 @@ private static List ParseClassicCallOuts(ValueMatch match, ref ReadOnly }); } } + return callOuts; } } diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesBlock.cs b/src/Elastic.Markdown/Myst/Directives/AppliesBlock.cs index 799aec1d9..695ab00e8 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesBlock.cs @@ -12,73 +12,5 @@ public class AppliesBlock(DirectiveBlockParser parser, ParserContext context) : { public override string Directive => "mermaid"; - public Deployment? Deployment { get; private set; } - - public override void FinalizeAndValidate(ParserContext context) - { - if (TryGetAvailability("cloud", out var version)) - { - Deployment ??= new Deployment(); - Deployment.Cloud ??= new CloudManagedDeployment(); - Deployment.Cloud.Serverless = version; - Deployment.Cloud.Hosted = version; - } - if (TryGetAvailability("self", out version)) - { - Deployment ??= new Deployment(); - Deployment.SelfManaged ??= new SelfManagedDeployment(); - Deployment.SelfManaged.Ece = version; - Deployment.SelfManaged.Eck = version; - Deployment.SelfManaged.Stack = version; - } - - if (TryGetAvailability("stack", out version)) - { - Deployment ??= new Deployment(); - Deployment.SelfManaged ??= new SelfManagedDeployment(); - Deployment.SelfManaged.Stack = version; - } - if (TryGetAvailability("ece", out version)) - { - Deployment ??= new Deployment(); - Deployment.SelfManaged ??= new SelfManagedDeployment(); - Deployment.SelfManaged.Ece = version; - } - if (TryGetAvailability("eck", out version)) - { - Deployment ??= new Deployment(); - Deployment.SelfManaged ??= new SelfManagedDeployment(); - Deployment.SelfManaged.Eck = version; - } - if (TryGetAvailability("hosted", out version)) - { - Deployment ??= new Deployment(); - Deployment.Cloud ??= new CloudManagedDeployment(); - Deployment.Cloud.Hosted = version; - } - if (TryGetAvailability("serverless", out version)) - { - Deployment ??= new Deployment(); - Deployment.Cloud ??= new CloudManagedDeployment(); - Deployment.Cloud.Serverless = version; - } - - if (Deployment is null) - this.EmitError("{applies} block with no product availability specified"); - - var index = Parent?.IndexOf(this); - if (Parent is not null && index > 0) - { - var i = index - 1 ?? 0; - var prevSib = Parent[i]; - if (prevSib is not HeadingBlock) - this.EmitError("{applies} should follow a heading"); - } - - bool TryGetAvailability(string key, out ProductAvailability? semVersion) - { - semVersion = null; - return Prop(key) is { } v && ProductAvailability.TryParse(v, out semVersion); - } - } + public override void FinalizeAndValidate(ParserContext context) => this.EmitWarning("{applies} is deprecated, please use the {apply} directive"); } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index 22ad8b4ec..0682980b6 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -153,12 +153,19 @@ public override BlockState TryOpen(BlockProcessor processor) return BlockState.None; } - if (line.IndexOf("{") == -1) + if (line.IndexOf("{") <= -1) return BlockState.None; if (line.IndexOf("}") == -1) return BlockState.None; + var span = line.AsSpan(); + var lastIndent = Math.Max(span.LastIndexOf("`"), span.LastIndexOf(":")); + var startApplies = span.IndexOf("{applies_to}"); + var startOpen = span.IndexOf("{"); + if (startOpen > lastIndent + 1 || startApplies != -1) + return BlockState.None; + return base.TryOpen(processor); } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 9fb5155fc..645a8f459 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -36,8 +36,7 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case MermaidBlock mermaidBlock: WriteMermaid(renderer, mermaidBlock); return; - case AppliesBlock appliesBlock: - WriteApplies(renderer, appliesBlock); + case AppliesBlock: //deprecated scheduled for removal return; case FigureBlock imageBlock: WriteFigure(renderer, imageBlock); @@ -189,15 +188,6 @@ private void WriteMermaid(HtmlRenderer renderer, MermaidBlock block) RenderRazorSliceRawContent(slice, renderer, block); } - private void WriteApplies(HtmlRenderer renderer, AppliesBlock block) - { - if (block.Deployment is null || block.Deployment == Deployment.All) - return; - - var slice = Applies.Create(block.Deployment); - RenderRazorSliceNoContent(slice, renderer); - } - private void WriteLiteralIncludeBlock(HtmlRenderer renderer, IncludeBlock block) { if (!block.Found || block.IncludePath is null) diff --git a/src/Elastic.Markdown/Myst/FrontMatter/Applicability.cs b/src/Elastic.Markdown/Myst/FrontMatter/Applicability.cs new file mode 100644 index 000000000..1b6a7972d --- /dev/null +++ b/src/Elastic.Markdown/Myst/FrontMatter/Applicability.cs @@ -0,0 +1,174 @@ +// 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.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Elastic.Markdown.Helpers; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Myst.FrontMatter; + +[YamlSerializable] +public record ApplicabilityOverTime : IReadOnlyCollection +{ + private readonly IReadOnlyCollection _items; + public ApplicabilityOverTime(Applicability[] items) => _items = items; + + // [version] + public static bool TryParse(string? value, out ApplicabilityOverTime? availability) + { + availability = null; + if (string.IsNullOrWhiteSpace(value) || string.Equals(value.Trim(), "all", StringComparison.InvariantCultureIgnoreCase)) + { + availability = GenerallyAvailable; + return true; + } + + var items = value.Split(','); + var applications = new List(items.Length); + foreach (var item in items) + { + if (Applicability.TryParse(item.Trim(), out var a)) + applications.Add(a); + } + + if (applications.Count == 0) + return false; + + availability = new ApplicabilityOverTime(applications.ToArray()); + return true; + } + + public virtual bool Equals(ApplicabilityOverTime? other) + { + if ((object)this == other) + return true; + + if ((object?)other is null || EqualityContract != other.EqualityContract) + return false; + + var comparer = StructuralComparisons.StructuralEqualityComparer; + return comparer.Equals(_items, other._items); + } + + public override int GetHashCode() + { + var comparer = StructuralComparisons.StructuralEqualityComparer; + return + EqualityComparer.Default.GetHashCode(EqualityContract) * -1521134295 + + comparer.GetHashCode(_items); + } + + + public static explicit operator ApplicabilityOverTime(string b) + { + var productAvailability = TryParse(b, out var version) ? version : null; + return productAvailability ?? throw new ArgumentException($"'{b}' is not a valid applicability string array."); + } + + public static ApplicabilityOverTime GenerallyAvailable { get; } + = new([Applicability.GenerallyAvailable]); + + public override string ToString() + { + if (this == GenerallyAvailable) + return "all"; + var sb = new StringBuilder(); + foreach (var item in _items) + sb.Append(item).Append(", "); + return sb.ToString(); + } + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => _items.Count; +} + +[YamlSerializable] +public record Applicability +{ + public ProductLifecycle Lifecycle { get; init; } + public SemVersion? Version { get; init; } + + public static Applicability GenerallyAvailable { get; } = new() + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = AllVersions.Instance + }; + + public override string ToString() + { + if (this == GenerallyAvailable) + return "all"; + var sb = new StringBuilder(); + var lifecycle = Lifecycle switch + { + ProductLifecycle.TechnicalPreview => "preview", + ProductLifecycle.Beta => "beta", + ProductLifecycle.Development => "dev", + ProductLifecycle.Deprecated => "deprecated", + ProductLifecycle.Coming => "coming", + ProductLifecycle.Discontinued => "discontinued", + ProductLifecycle.Unavailable => "unavailable", + ProductLifecycle.GenerallyAvailable => "ga", + _ => throw new ArgumentOutOfRangeException() + }; + sb.Append(lifecycle); + if (Version is not null && Version != AllVersions.Instance) + sb.Append(" ").Append(Version); + return sb.ToString(); + } + + public static explicit operator Applicability(string b) + { + var productAvailability = TryParse(b, out var version) ? version : TryParse(b + ".0", out version) ? version : null; + return productAvailability ?? throw new ArgumentException($"'{b}' is not a valid applicability string."); + } + + public static bool TryParse(string? value, [NotNullWhen(true)] out Applicability? availability) + { + if (string.IsNullOrWhiteSpace(value) || string.Equals(value.Trim(), "all", StringComparison.InvariantCultureIgnoreCase)) + { + availability = GenerallyAvailable; + return true; + } + + var tokens = value.Split(" ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 1) + { + availability = null; + return false; + } + + var lifecycle = tokens[0].ToLowerInvariant() switch + { + "preview" => ProductLifecycle.TechnicalPreview, + "tech-preview" => ProductLifecycle.TechnicalPreview, + "beta" => ProductLifecycle.Beta, + "dev" => ProductLifecycle.Development, + "development" => ProductLifecycle.Development, + "deprecated" => ProductLifecycle.Deprecated, + "coming" => ProductLifecycle.Coming, + "discontinued" => ProductLifecycle.Discontinued, + "unavailable" => ProductLifecycle.Unavailable, + "ga" => ProductLifecycle.GenerallyAvailable, + _ => throw new ArgumentOutOfRangeException(nameof(tokens), tokens, $"Unknown product lifecycle: {tokens[0]}") + }; + + var version = tokens.Length < 2 + ? null + : tokens[1] switch + { + null => AllVersions.Instance, + "all" => AllVersions.Instance, + "" => AllVersions.Instance, + var t => SemVersionConverter.TryParse(t, out var v) ? v : null + }; + availability = new Applicability { Version = version, Lifecycle = lifecycle }; + return true; + } +} diff --git a/src/Elastic.Markdown/Myst/FrontMatter/ApplicableTo.cs b/src/Elastic.Markdown/Myst/FrontMatter/ApplicableTo.cs new file mode 100644 index 000000000..f0f241cee --- /dev/null +++ b/src/Elastic.Markdown/Myst/FrontMatter/ApplicableTo.cs @@ -0,0 +1,257 @@ +// 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 YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Myst.FrontMatter; + +[YamlSerializable] +public record ApplicableTo +{ + [YamlMember(Alias = "stack")] + public ApplicabilityOverTime? Stack { get; set; } + + [YamlMember(Alias = "deployment")] + public DeploymentApplicability? Deployment { get; set; } + + [YamlMember(Alias = "serverless")] + public ServerlessProjectApplicability? Serverless { get; set; } + + [YamlMember(Alias = "product")] + public ApplicabilityOverTime? Product { get; set; } + + public static ApplicableTo All { get; } = new() + { + Stack = ApplicabilityOverTime.GenerallyAvailable, + Serverless = ServerlessProjectApplicability.All, + Deployment = DeploymentApplicability.All, + Product = ApplicabilityOverTime.GenerallyAvailable + }; +} + +[YamlSerializable] +public record DeploymentApplicability +{ + [YamlMember(Alias = "self")] + public ApplicabilityOverTime? Self { get; set; } + + [YamlMember(Alias = "ece")] + public ApplicabilityOverTime? Ece { get; set; } + + [YamlMember(Alias = "eck")] + public ApplicabilityOverTime? Eck { get; set; } + + [YamlMember(Alias = "ess")] + public ApplicabilityOverTime? Ess { get; set; } + + public static DeploymentApplicability All { get; } = new() + { + Ece = ApplicabilityOverTime.GenerallyAvailable, + Eck = ApplicabilityOverTime.GenerallyAvailable, + Ess = ApplicabilityOverTime.GenerallyAvailable, + Self = ApplicabilityOverTime.GenerallyAvailable + }; +} + +[YamlSerializable] +public record ServerlessProjectApplicability +{ + [YamlMember(Alias = "elasticsearch")] + public ApplicabilityOverTime? Elasticsearch { get; set; } + + [YamlMember(Alias = "observability")] + public ApplicabilityOverTime? Observability { get; set; } + + [YamlMember(Alias = "security")] + public ApplicabilityOverTime? Security { get; set; } + + /// + /// Returns if all projects share the same applicability + /// + public ApplicabilityOverTime? AllProjects => + Elasticsearch == Observability && Observability == Security + ? Elasticsearch + : null; + + public static ServerlessProjectApplicability All { get; } = new() + { + Elasticsearch = ApplicabilityOverTime.GenerallyAvailable, + Observability = ApplicabilityOverTime.GenerallyAvailable, + Security = ApplicabilityOverTime.GenerallyAvailable + }; +} + +public class ApplicableToConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(ApplicableTo); + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + if (parser.TryConsume(out var value)) + { + if (string.IsNullOrWhiteSpace(value.Value)) + return ApplicableTo.All; + if (string.Equals(value.Value, "all", StringComparison.InvariantCultureIgnoreCase)) + return ApplicableTo.All; + } + + var deserialized = rootDeserializer.Invoke(typeof(Dictionary)); + if (deserialized is not Dictionary { Count: > 0 } dictionary) + return null; + + var applicableTo = new ApplicableTo(); + if (TryGetApplicabilityOverTime(dictionary, "stack", out var stackAvailability)) + applicableTo.Stack = stackAvailability; + + if (TryGetApplicabilityOverTime(dictionary, "product", out var productAvailability)) + applicableTo.Product = productAvailability; + + AssignServerless(dictionary, applicableTo); + AssignDeploymentType(dictionary, applicableTo); + + if (TryGetDeployment(dictionary, out var deployment)) + applicableTo.Deployment = deployment; + + if (TryGetProjectApplicability(dictionary, out var serverless)) + applicableTo.Serverless = serverless; + + return applicableTo; + } + + private static void AssignDeploymentType(Dictionary dictionary, ApplicableTo applicableTo) + { + if (!dictionary.TryGetValue("deployment", out var deploymentType)) + return; + + if (deploymentType is null || deploymentType is string s && string.IsNullOrWhiteSpace(s)) + applicableTo.Deployment = DeploymentApplicability.All; + else if (deploymentType is string deploymentTypeString) + { + var av = ApplicabilityOverTime.TryParse(deploymentTypeString, out var a) ? a : null; + applicableTo.Deployment = new DeploymentApplicability + { + Ece = av, + Eck = av, + Ess = av, + Self = av + }; + } + else if (deploymentType is Dictionary deploymentDictionary) + { + if (TryGetDeployment(deploymentDictionary, out var applicability)) + applicableTo.Deployment = applicability; + } + } + + private static bool TryGetDeployment(Dictionary dictionary, [NotNullWhen(true)] out DeploymentApplicability? applicability) + { + applicability = null; + var d = new DeploymentApplicability(); + var assigned = false; + if (TryGetApplicabilityOverTime(dictionary, "ece", out var ece)) + { + d.Ece = ece; + assigned = true; + } + if (TryGetApplicabilityOverTime(dictionary, "eck", out var eck)) + { + d.Eck = eck; + assigned = true; + } + + if (TryGetApplicabilityOverTime(dictionary, "ess", out var ess)) + { + d.Ess = ess; + assigned = true; + } + + if (TryGetApplicabilityOverTime(dictionary, "self", out var self)) + { + d.Self = self; + assigned = true; + } + + if (assigned) + { + applicability = d; + return true; + } + + return false; + } + + private static void AssignServerless(Dictionary dictionary, ApplicableTo applicableTo) + { + if (!dictionary.TryGetValue("serverless", out var serverless)) + return; + + if (serverless is null || serverless is string s && string.IsNullOrWhiteSpace(s)) + applicableTo.Serverless = ServerlessProjectApplicability.All; + else if (serverless is string serverlessString) + { + var av = ApplicabilityOverTime.TryParse(serverlessString, out var a) ? a : null; + applicableTo.Serverless = new ServerlessProjectApplicability + { + Elasticsearch = av, + Observability = av, + Security = av + }; + } + else if (serverless is Dictionary serverlessDictionary) + { + if (TryGetProjectApplicability(serverlessDictionary, out var applicability)) + applicableTo.Serverless = applicability; + } + } + + private static bool TryGetProjectApplicability( + Dictionary dictionary, + [NotNullWhen(true)] out ServerlessProjectApplicability? applicability + ) + { + applicability = null; + var serverlessAvailability = new ServerlessProjectApplicability(); + var assigned = false; + if (TryGetApplicabilityOverTime(dictionary, "elasticsearch", out var elasticsearch)) + { + serverlessAvailability.Elasticsearch = elasticsearch; + assigned = true; + } + if (TryGetApplicabilityOverTime(dictionary, "observability", out var observability)) + { + serverlessAvailability.Observability = observability; + assigned = true; + } + + if (TryGetApplicabilityOverTime(dictionary, "security", out var security)) + { + serverlessAvailability.Security = security; + assigned = true; + } + + if (!assigned) + return false; + applicability = serverlessAvailability; + return true; + } + + private static bool TryGetApplicabilityOverTime(Dictionary dictionary, string key, out ApplicabilityOverTime? availability) + { + availability = null; + if (!dictionary.TryGetValue(key, out var target)) + return false; + + if (target is null || target is string s && string.IsNullOrWhiteSpace(s)) + availability = ApplicabilityOverTime.GenerallyAvailable; + else if (target is string stackString) + availability = ApplicabilityOverTime.TryParse(stackString, out var a) ? a : null; + return availability is not null; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + serializer.Invoke(value, type); +} diff --git a/src/Elastic.Markdown/Myst/FrontMatter/Deployment.cs b/src/Elastic.Markdown/Myst/FrontMatter/Deployment.cs index 9d4a9ff04..4d6c1cba6 100644 --- a/src/Elastic.Markdown/Myst/FrontMatter/Deployment.cs +++ b/src/Elastic.Markdown/Myst/FrontMatter/Deployment.cs @@ -9,6 +9,7 @@ namespace Elastic.Markdown.Myst.FrontMatter; [YamlSerializable] +[Obsolete("Use YamlFrontMatter.Apply instead see also DeploymentMode")] public record Deployment { [YamlMember(Alias = "self")] @@ -25,42 +26,45 @@ public record Deployment } [YamlSerializable] +[Obsolete("Use YamlFrontMatter.Apply instead")] public record SelfManagedDeployment { [YamlMember(Alias = "stack")] - public ProductAvailability? Stack { get; set; } + public Applicability? Stack { get; set; } [YamlMember(Alias = "ece")] - public ProductAvailability? Ece { get; set; } + public Applicability? Ece { get; set; } [YamlMember(Alias = "eck")] - public ProductAvailability? Eck { get; set; } + public Applicability? Eck { get; set; } public static SelfManagedDeployment All { get; } = new() { - Stack = ProductAvailability.GenerallyAvailable, - Ece = ProductAvailability.GenerallyAvailable, - Eck = ProductAvailability.GenerallyAvailable + Stack = Applicability.GenerallyAvailable, + Ece = Applicability.GenerallyAvailable, + Eck = Applicability.GenerallyAvailable }; } [YamlSerializable] +[Obsolete("Use YamlFrontMatter.Apply instead")] public record CloudManagedDeployment { [YamlMember(Alias = "hosted")] - public ProductAvailability? Hosted { get; set; } + public Applicability? Hosted { get; set; } [YamlMember(Alias = "serverless")] - public ProductAvailability? Serverless { get; set; } + public Applicability? Serverless { get; set; } public static CloudManagedDeployment All { get; } = new() { - Hosted = ProductAvailability.GenerallyAvailable, - Serverless = ProductAvailability.GenerallyAvailable + Hosted = Applicability.GenerallyAvailable, + Serverless = Applicability.GenerallyAvailable }; - } +#pragma warning disable CS0618 // Type or member is obsolete +[Obsolete("Use DeploymentAvailability instead")] public class DeploymentConverter : IYamlTypeConverter { public bool Accepts(Type type) => type == typeof(Deployment); @@ -74,6 +78,7 @@ public class DeploymentConverter : IYamlTypeConverter if (string.Equals(value.Value, "all", StringComparison.InvariantCultureIgnoreCase)) return Deployment.All; } + var deserialized = rootDeserializer.Invoke(typeof(Dictionary)); if (deserialized is not Dictionary { Count: > 0 } dictionary) return null; @@ -86,6 +91,7 @@ public class DeploymentConverter : IYamlTypeConverter deployment.Cloud.Serverless = version; deployment.Cloud.Hosted = version; } + if (TryGetAvailability("self", out version)) { deployment.SelfManaged ??= new SelfManagedDeployment(); @@ -99,36 +105,41 @@ public class DeploymentConverter : IYamlTypeConverter deployment.SelfManaged ??= new SelfManagedDeployment(); deployment.SelfManaged.Stack = version; } + if (TryGetAvailability("ece", out version)) { deployment.SelfManaged ??= new SelfManagedDeployment(); deployment.SelfManaged.Ece = version; } + if (TryGetAvailability("eck", out version)) { deployment.SelfManaged ??= new SelfManagedDeployment(); deployment.SelfManaged.Eck = version; } + if (TryGetAvailability("hosted", out version)) { deployment.Cloud ??= new CloudManagedDeployment(); deployment.Cloud.Hosted = version; } + if (TryGetAvailability("serverless", out version)) { deployment.Cloud ??= new CloudManagedDeployment(); deployment.Cloud.Serverless = version; } + return deployment; - bool TryGetAvailability(string key, out ProductAvailability? semVersion) + bool TryGetAvailability(string key, out Applicability? semVersion) { semVersion = null; - return dictionary.TryGetValue(key, out var v) && ProductAvailability.TryParse(v, out semVersion); + return dictionary.TryGetValue(key, out var v) && Applicability.TryParse(v, out semVersion); } - } public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type); } +#pragma warning restore CS0618 // Type or member is obsolete diff --git a/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs b/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs index c8d7096f9..8a88772e2 100644 --- a/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs +++ b/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs @@ -18,6 +18,6 @@ public class YamlFrontMatter [YamlMember(Alias = "sub")] public Dictionary? Properties { get; set; } - [YamlMember(Alias = "applies")] - public Deployment? AppliesTo { get; set; } + [YamlMember(Alias = "applies_to")] + public ApplicableTo? AppliesTo { get; set; } } diff --git a/src/Elastic.Markdown/Myst/FrontMatter/ProductAvailability.cs b/src/Elastic.Markdown/Myst/FrontMatter/ProductAvailability.cs deleted file mode 100644 index c11cd1858..000000000 --- a/src/Elastic.Markdown/Myst/FrontMatter/ProductAvailability.cs +++ /dev/null @@ -1,62 +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.Helpers; -using YamlDotNet.Serialization; - -namespace Elastic.Markdown.Myst.FrontMatter; - -[YamlSerializable] -public record ProductAvailability -{ - public ProductLifecycle Lifecycle { get; init; } - public SemVersion? Version { get; init; } - - public static ProductAvailability GenerallyAvailable { get; } = new() - { - Lifecycle = ProductLifecycle.GenerallyAvailable, - Version = AllVersions.Instance - }; - - // [version] - public static bool TryParse(string? value, out ProductAvailability? availability) - { - if (string.IsNullOrWhiteSpace(value) || string.Equals(value.Trim(), "all", StringComparison.InvariantCultureIgnoreCase)) - { - availability = GenerallyAvailable; - return true; - } - - var tokens = value.Split(" ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length < 1) - { - availability = null; - return false; - } - var lifecycle = tokens[0].ToLowerInvariant() switch - { - "preview" => ProductLifecycle.TechnicalPreview, - "tech-preview" => ProductLifecycle.TechnicalPreview, - "beta" => ProductLifecycle.Beta, - "dev" => ProductLifecycle.Development, - "development" => ProductLifecycle.Development, - "deprecated" => ProductLifecycle.Deprecated, - "coming" => ProductLifecycle.Coming, - "discontinued" => ProductLifecycle.Discontinued, - "unavailable" => ProductLifecycle.Unavailable, - "ga" => ProductLifecycle.GenerallyAvailable, - _ => throw new ArgumentOutOfRangeException(nameof(tokens), tokens, $"Unknown product lifecycle: {tokens[0]}") - }; - - var version = tokens.Length < 2 ? null : tokens[1] switch - { - null => AllVersions.Instance, - "all" => AllVersions.Instance, - "" => AllVersions.Instance, - var t => SemVersionConverter.TryParse(t, out var v) ? v : null - }; - availability = new ProductAvailability { Version = version, Lifecycle = lifecycle }; - return true; - } -} diff --git a/src/Elastic.Markdown/Myst/Settings/StructuredSettings.cs b/src/Elastic.Markdown/Myst/Settings/StructuredSettings.cs index afce6d349..c3faa0b15 100644 --- a/src/Elastic.Markdown/Myst/Settings/StructuredSettings.cs +++ b/src/Elastic.Markdown/Myst/Settings/StructuredSettings.cs @@ -37,7 +37,7 @@ public record Setting [YamlMember(Alias = "description")] public string? Description { get; set; } [YamlMember(Alias = "applies")] - public ProductAvailability? Applies { get; set; } + public Applicability? Applies { get; set; } [YamlMember(Alias = "type")] public SettingMutability Mutability { get; set; } [YamlMember(Alias = "options")] diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 472407d35..06d0c47ad 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -17,7 +17,10 @@ public static T Deserialize(string yaml) var deserializer = new StaticDeserializerBuilder(new DocsBuilderYamlStaticContext()) .IgnoreUnmatchedProperties() .WithTypeConverter(new SemVersionConverter()) +#pragma warning disable CS0618 // Type or member is obsolete .WithTypeConverter(new DeploymentConverter()) + .WithTypeConverter(new ApplicableToConverter()) +#pragma warning restore CS0618 // Type or member is obsolete .Build(); var frontMatter = deserializer.Deserialize(input); diff --git a/src/Elastic.Markdown/Slices/Directives/ApplicableTo.cshtml b/src/Elastic.Markdown/Slices/Directives/ApplicableTo.cshtml new file mode 100644 index 000000000..fc0950d3c --- /dev/null +++ b/src/Elastic.Markdown/Slices/Directives/ApplicableTo.cshtml @@ -0,0 +1,122 @@ +@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", 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.Security is not null) + { + @RenderProduct("Serverless Security", Model.Serverless.Security) + } + 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 string GetLifeCycleClass(ProductLifecycle cycle) + { + switch (cycle) + { + case ProductLifecycle.Deprecated: + case ProductLifecycle.Coming: + case ProductLifecycle.Discontinued: + case ProductLifecycle.Unavailable: + return "muted"; + case ProductLifecycle.GenerallyAvailable: + case ProductLifecycle.TechnicalPreview: + case ProductLifecycle.Beta: + case ProductLifecycle.Development: + return "primary"; + default: + throw new ArgumentOutOfRangeException(nameof(cycle), cycle, null); + } + } + private 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, ApplicabilityOverTime applications) + { + foreach (var applicability in applications) + { + var c = GetLifeCycleClass(applicability.Lifecycle); + + @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/Applies.cshtml b/src/Elastic.Markdown/Slices/Directives/Applies.cshtml deleted file mode 100644 index f34525eb8..000000000 --- a/src/Elastic.Markdown/Slices/Directives/Applies.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@using Elastic.Markdown.Myst.FrontMatter -@inherits RazorSlice -

- - Applies To: - - @if (Model.SelfManaged is not null) - { - if (Model.SelfManaged.Stack is not null) - { - @RenderProduct("Elastic Stack", Model.SelfManaged.Stack) - } - if (Model.SelfManaged.Ece is not null) - { - @RenderProduct("Elastic Cloud Enterprise", Model.SelfManaged.Ece) - } - if (Model.SelfManaged.Eck is not null) - { - @RenderProduct("Elastic Cloud Kubernetes", Model.SelfManaged.Eck) - } - } - @if (Model.Cloud is not null) - { - if (Model.Cloud.Hosted is not null) - { - @RenderProduct("Elastic Cloud Hosted", Model.Cloud.Hosted) - } - if (Model.Cloud.Serverless is not null) - { - @RenderProduct("Serverless", Model.Cloud.Serverless) - } - } -

- -@functions { - - private string GetLifeCycleClass(ProductLifecycle cycle) - { - switch (cycle) - { - case ProductLifecycle.Deprecated: - case ProductLifecycle.Coming: - case ProductLifecycle.Discontinued: - case ProductLifecycle.Unavailable: - return "muted"; - case ProductLifecycle.GenerallyAvailable: - case ProductLifecycle.TechnicalPreview: - case ProductLifecycle.Beta: - case ProductLifecycle.Development: - return "primary"; - default: - throw new ArgumentOutOfRangeException(nameof(cycle), cycle, null); - } - } - private 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, ProductAvailability product) - { - var c = GetLifeCycleClass(product.Lifecycle); - - @name - @if (product.Lifecycle != ProductLifecycle.GenerallyAvailable) - { - @GetLifeCycleName(product.Lifecycle) - } - @if (product.Version is not null and not AllVersions) - { - (@product.Version) - } - - return HtmlString.Empty; - } -} \ No newline at end of file diff --git a/src/Elastic.Markdown/Slices/Index.cshtml b/src/Elastic.Markdown/Slices/Index.cshtml index 14cd141e3..85dfdb3e9 100644 --- a/src/Elastic.Markdown/Slices/Index.cshtml +++ b/src/Elastic.Markdown/Slices/Index.cshtml @@ -21,7 +21,7 @@ @(new HtmlString(Markdown.ToHtml("# " + Model.TitleRaw))) @if (Model.Applies is not null) { - await RenderPartialAsync(Applies.Create(Model.Applies)); + await RenderPartialAsync(ApplicableTo.Create(Model.Applies)); } @(new HtmlString(Model.MarkdownHtml)) diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs index 4680f926b..e50ea5651 100644 --- a/src/Elastic.Markdown/Slices/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/_ViewModels.cs @@ -20,7 +20,7 @@ public class IndexViewModel public required string NavigationHtml { get; init; } public required string? UrlPathPrefix { get; init; } public required string? GithubEditUrl { get; init; } - public required Deployment? Applies { get; init; } + public required ApplicableTo? Applies { get; init; } public required bool AllowIndexing { get; init; } } diff --git a/src/docs-assembler/docs-assembler.csproj b/src/docs-assembler/docs-assembler.csproj index 5caeaa4a2..2c29e96ed 100644 --- a/src/docs-assembler/docs-assembler.csproj +++ b/src/docs-assembler/docs-assembler.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/src/docs-builder/docs-builder.csproj b/src/docs-builder/docs-builder.csproj index b3597c892..16fa62385 100644 --- a/src/docs-builder/docs-builder.csproj +++ b/src/docs-builder/docs-builder.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/tests/Elastic.Markdown.Tests/Directives/AppliesBlockTests.cs b/tests/Elastic.Markdown.Tests/Directives/AppliesBlockTests.cs deleted file mode 100644 index b24729d45..000000000 --- a/tests/Elastic.Markdown.Tests/Directives/AppliesBlockTests.cs +++ /dev/null @@ -1,80 +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; -using Elastic.Markdown.Myst.Directives; -using FluentAssertions; - -namespace Elastic.Markdown.Tests.Directives; - -public class AppliesBlockTests(ITestOutputHelper output) : DirectiveTest(output, -""" -# heading -:::{applies} -:eck: unavailable -::: -""" -) -{ - [Fact] - public void ParsesBlock() => Block.Should().NotBeNull(); - - [Fact] - public void IncludesProductAvailability() => - Html.Should().Contain("Unavailable") - .And.Contain("Elastic Cloud Kubernetes") - .And.Contain("Applies To:"); - - - [Fact] - public void NoErrors() => Collector.Diagnostics.Should().BeEmpty(); -} - -public class EmptyAppliesBlock(ITestOutputHelper output) : DirectiveTest(output, -""" - -A paragraph that's not a heading - -```{applies} -``` -""" -) -{ - [Fact] - public void ParsesBlock() => Block.Should().NotBeNull(); - - [Fact] - public void DoesNotRender() => - Html.Should().Be("

A paragraph that's not a heading

"); - - [Fact] - public void EmitErrorOnEmptyBlock() - { - Collector.Diagnostics.Should().NotBeNullOrEmpty().And.HaveCount(2); - Collector.Diagnostics.Should().OnlyContain(d => d.Severity == Severity.Error); - Collector.Diagnostics.Should() - .Contain(d => d.Message.Contains("{applies} block with no product availability specified")); - - Collector.Diagnostics.Should() - .Contain(d => d.Message.Contains("{applies} should follow a heading")); - } -} - -// ensures we allow for empty lines between heading and applies block -public class AppliesHeadingTests(ITestOutputHelper output) : DirectiveTest(output, -""" -# heading - - - -```{applies} -:eck: unavailable -``` -""" -) -{ - [Fact] - public void NoErrors() => Collector.Diagnostics.Should().BeEmpty(); -} - diff --git a/tests/Elastic.Markdown.Tests/FrontMatter/ProductConstraintTests.cs b/tests/Elastic.Markdown.Tests/FrontMatter/ProductConstraintTests.cs deleted file mode 100644 index e3f04105a..000000000 --- a/tests/Elastic.Markdown.Tests/FrontMatter/ProductConstraintTests.cs +++ /dev/null @@ -1,159 +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.Myst.FrontMatter; -using Elastic.Markdown.Tests.Directives; -using FluentAssertions; -using static Elastic.Markdown.Myst.FrontMatter.ProductLifecycle; - -namespace Elastic.Markdown.Tests.FrontMatter; - -public class ProductConstraintTests(ITestOutputHelper output) : DirectiveTest(output, -""" ---- -navigation_title: "Documentation Guide" -applies: - stack: ga 8.1 - serverless: tech-preview - hosted: beta 8.1.1 - eck: beta 3.0.2 - ece: unavailable ---- -""" -) -{ - [Fact] - public void Assert() - { - File.YamlFrontMatter.Should().NotBeNull(); - var appliesTo = File.YamlFrontMatter!.AppliesTo; - appliesTo.Should().NotBeNull(); - appliesTo!.Cloud.Should().NotBeNull(); - appliesTo.Cloud!.Serverless.Should().BeEquivalentTo(new ProductAvailability { Lifecycle = TechnicalPreview }); - appliesTo.Cloud!.Hosted.Should().BeEquivalentTo(new ProductAvailability { Lifecycle = Beta, Version = new(8, 1, 1) }); - appliesTo.SelfManaged.Should().NotBeNull(); - appliesTo.SelfManaged!.Eck.Should().BeEquivalentTo(new ProductAvailability { Lifecycle = Beta, Version = new(3, 0, 2) }); - appliesTo.SelfManaged!.Ece.Should().BeEquivalentTo(new ProductAvailability { Lifecycle = Unavailable }); - appliesTo.SelfManaged!.Stack.Should().BeEquivalentTo(new ProductAvailability { Lifecycle = GenerallyAvailable, Version = new(8, 1, 0) }); - } -} - -public abstract class ParsingTests(ITestOutputHelper output, string moniker, ProductAvailability? expected) - : DirectiveTest(output, -$""" ---- -navigation_title: "Documentation Guide" -applies: - serverless: {moniker} ---- -""" -) -{ - [Fact] - public void Assert() - { - File.YamlFrontMatter.Should().NotBeNull(); - var appliesTo = File.YamlFrontMatter!.AppliesTo; - appliesTo.Should().NotBeNull(); - appliesTo!.Cloud.Should().NotBeNull(); - appliesTo.Cloud!.Serverless.Should().BeEquivalentTo(expected); - } -} - -public class ParsesGa(ITestOutputHelper output) : ParsingTests(output, "ga", new() { Lifecycle = GenerallyAvailable }); -public class ParsesDev(ITestOutputHelper output) : ParsingTests(output, "dev", new() { Lifecycle = Development }); -public class ParsesDevelopment(ITestOutputHelper output) : ParsingTests(output, "development", new() { Lifecycle = Development }); -public class ParsesBeta(ITestOutputHelper output) : ParsingTests(output, "beta", new() { Lifecycle = Beta }); -public class ParsesComing(ITestOutputHelper output) : ParsingTests(output, "coming", new() { Lifecycle = Coming }); -public class ParsesDeprecated(ITestOutputHelper output) : ParsingTests(output, "deprecated", new() { Lifecycle = Deprecated }); -public class ParsesDiscontinued(ITestOutputHelper output) : ParsingTests(output, "discontinued", new() { Lifecycle = Discontinued }); -public class ParsesUnavailable(ITestOutputHelper output) : ParsingTests(output, "unavailable", new() { Lifecycle = Unavailable }); -public class ParsesTechnicalPreview(ITestOutputHelper output) : ParsingTests(output, "tech-preview", new() { Lifecycle = TechnicalPreview }); -public class ParsesPreview(ITestOutputHelper output) : ParsingTests(output, "preview", new() { Lifecycle = TechnicalPreview }); -public class ParsesEmpty(ITestOutputHelper output) : ParsingTests(output, "", ProductAvailability.GenerallyAvailable); -public class ParsesAll(ITestOutputHelper output) : ParsingTests(output, "all", ProductAvailability.GenerallyAvailable); -public class ParsesWithVersion(ITestOutputHelper output) : ParsingTests(output, "ga 7.7.0", new() { Lifecycle = GenerallyAvailable, Version = new(7, 7, 0) }); -public class ParsesWithAllVersion(ITestOutputHelper output) : ParsingTests(output, "ga all", new() { Lifecycle = GenerallyAvailable, Version = AllVersions.Instance }); - -public class CanSpecifyAllForProductVersions(ITestOutputHelper output) : DirectiveTest(output, -""" ---- -navigation_title: "Documentation Guide" -applies: - stack: all ---- -""" -) -{ - [Fact] - public void Assert() => - File.YamlFrontMatter!.AppliesTo!.SelfManaged!.Stack.Should().BeEquivalentTo(ProductAvailability.GenerallyAvailable); -} - -public class EmptyProductVersionMeansAll(ITestOutputHelper output) : DirectiveTest(output, -""" ---- -navigation_title: "Documentation Guide" -applies: - stack: ---- -""" -) -{ - [Fact] - public void Assert() => - File.YamlFrontMatter!.AppliesTo!.SelfManaged!.Stack.Should().BeEquivalentTo(ProductAvailability.GenerallyAvailable); -} - -public class EmptyCloudSetsAllCloudProductsToAll(ITestOutputHelper output) : DirectiveTest(output, -""" ---- -navigation_title: "Documentation Guide" -applies: - cloud: ---- -""" -) -{ - [Fact] - public void Assert() => - File.YamlFrontMatter!.AppliesTo!.Cloud!.Hosted.Should().BeEquivalentTo(ProductAvailability.GenerallyAvailable); -} - -public class EmptySelfSetsAllSelfManagedProductsToAll(ITestOutputHelper output) : DirectiveTest(output, -""" ---- -navigation_title: "Documentation Guide" -applies: - self: - stack: deprecated 9.0.0 ---- -""" -) -{ - [Fact] - public void Assert() - { - File.YamlFrontMatter!.AppliesTo!.SelfManaged!.Eck.Should() - .BeEquivalentTo(ProductAvailability.GenerallyAvailable); - File.YamlFrontMatter!.AppliesTo!.SelfManaged!.Stack.Should() - .BeEquivalentTo(new ProductAvailability { Lifecycle = Deprecated, Version = new(9, 0, 0) }); - } -} - -public class CloudProductsOverwriteDeploymentType(ITestOutputHelper output) : DirectiveTest(output, -""" ---- -navigation_title: "Documentation Guide" -applies: - cloud: ---- -""" -) -{ - [Fact] - public void Assert() => - File.YamlFrontMatter!.AppliesTo!.Cloud!.Hosted.Should().BeEquivalentTo(ProductAvailability.GenerallyAvailable); -} - diff --git a/tests/authoring/Applicability/AppliesToDirective.fs b/tests/authoring/Applicability/AppliesToDirective.fs new file mode 100644 index 000000000..fb3148618 --- /dev/null +++ b/tests/authoring/Applicability/AppliesToDirective.fs @@ -0,0 +1,58 @@ +// 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 ``product availability``.``yaml directive`` + +open Elastic.Markdown.Myst.FrontMatter +open authoring +open authoring.MarkdownDocumentAssertions +open Swensen.Unquote +open Xunit +open Elastic.Markdown.Myst.CodeBlocks + +type ``piggy back off yaml formatting`` () = + static let markdown = Setup.Markdown """ +```yaml {applies_to} +serverless: + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 +``` +""" + + [] + let ``parses to AppliesDirective`` () = + let directives = markdown |> converts "index.md" |> parses + test <@ directives.Length = 1 @> + + directives |> appliesToDirective (ApplicableTo( + Serverless=ServerlessProjectApplicability( + Security=ApplicabilityOverTime.op_Explicit "ga 9.0.0", + Elasticsearch=ApplicabilityOverTime.op_Explicit "beta 9.1.0", + Observability=ApplicabilityOverTime.op_Explicit "discontinued 9.2.0" + ) + )) + +type ``plain block`` () = + static let markdown = Setup.Markdown """ +```{applies_to} +serverless: + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 +``` +""" + + [] + let ``parses to AppliesDirective`` () = + let directives = markdown |> converts "index.md" |> parses + test <@ directives.Length = 1 @> + + directives |> appliesToDirective (ApplicableTo( + Serverless=ServerlessProjectApplicability( + Security=ApplicabilityOverTime.op_Explicit "ga 9.0.0", + Elasticsearch=ApplicabilityOverTime.op_Explicit "beta 9.1.0", + Observability=ApplicabilityOverTime.op_Explicit "discontinued 9.2.0" + ) + )) \ No newline at end of file diff --git a/tests/authoring/Applicability/AppliesToFrontMatter.fs b/tests/authoring/Applicability/AppliesToFrontMatter.fs new file mode 100644 index 000000000..87fcb5426 --- /dev/null +++ b/tests/authoring/Applicability/AppliesToFrontMatter.fs @@ -0,0 +1,177 @@ +// 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 ``product availability``.``yaml frontmatter`` + +open Elastic.Markdown.Myst.FrontMatter +open JetBrains.Annotations +open Xunit +open authoring +open authoring.MarkdownDocumentAssertions + +let frontMatter ([]m: string) = + Setup.Document $"""--- +{m} +--- +# Document +""" + +type ``apply defaults to all`` () = + static let markdown = frontMatter """ +applies_to: +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo ApplicableTo.All + +type ``apply default to top level arguments`` () = + static let markdown = frontMatter """ +applies_to: + deployment: + serverless: +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo (ApplicableTo( + Deployment=DeploymentApplicability.All, + Serverless=ServerlessProjectApplicability.All + )) + +type ``parses serverless as string to set all projects`` () = + static let markdown = frontMatter """ +applies_to: + serverless: ga 9.0.0 +""" + [] + let ``apply matches expected`` () = + let expectedAvailability = ApplicabilityOverTime.op_Explicit "ga 9.0.0" + markdown |> appliesTo (ApplicableTo( + Serverless=ServerlessProjectApplicability( + Elasticsearch=expectedAvailability, + Observability=expectedAvailability, + Security=expectedAvailability + ) + )) + +type ``parses serverless projects`` () = + static let markdown = frontMatter """ +applies_to: + serverless: + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo (ApplicableTo( + Serverless=ServerlessProjectApplicability( + Security=ApplicabilityOverTime.op_Explicit "ga 9.0.0", + Elasticsearch=ApplicabilityOverTime.op_Explicit "beta 9.1.0", + Observability=ApplicabilityOverTime.op_Explicit "discontinued 9.2.0" + ) + )) + +type ``parses stack`` () = + static let markdown = frontMatter """ +applies_to: + stack: ga 9.1 +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo (ApplicableTo( + Stack=ApplicabilityOverTime.op_Explicit "ga 9.1.0" + )) + +type ``parses deployment as string to set all deployment targets`` () = + static let markdown = frontMatter """ +applies_to: + deployment: ga 9.0.0 +""" + [] + let ``apply matches expected`` () = + let expectedAvailability = ApplicabilityOverTime.op_Explicit "ga 9.0.0" + markdown |> appliesTo (ApplicableTo( + Deployment=DeploymentApplicability( + Eck=expectedAvailability, + Ess=expectedAvailability, + Ece=expectedAvailability, + Self=expectedAvailability + ) + )) + +type ``parses deployment types as individual properties`` () = + static let markdown = frontMatter """ +applies_to: + deployment: + eck: ga 9.0 + ess: beta 9.1 + ece: discontinued 9.2.0 + self: unavailable 9.3.0 +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo (ApplicableTo( + Deployment=DeploymentApplicability( + Eck=ApplicabilityOverTime.op_Explicit "ga 9.0", + Ess=ApplicabilityOverTime.op_Explicit "beta 9.1", + Ece=ApplicabilityOverTime.op_Explicit "discontinued 9.2.0", + Self=ApplicabilityOverTime.op_Explicit "unavailable 9.3.0" + ) + )) + +type ``parses product`` () = + static let markdown = frontMatter """ +applies_to: + product: coming 9.5 +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo (ApplicableTo( + Product=ApplicabilityOverTime.op_Explicit "coming 9.5.0" + )) + +type ``parses product multiple`` () = + static let markdown = frontMatter """ +applies_to: + product: coming 9.5, discontinued 9.7 +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo (ApplicableTo( + Product=ApplicabilityOverTime([ + Applicability.op_Explicit "coming 9.5"; + Applicability.op_Explicit "discontinued 9.7" + ] |> Array.ofList) + )) + +type ``lenient to defining types at top level`` () = + static let markdown = frontMatter """ +applies_to: + eck: ga 9.0 + ess: beta 9.1 + ece: discontinued 9.2.0 + self: unavailable 9.3.0 + security: ga 9.0.0 + elasticsearch: beta 9.1.0 + observability: discontinued 9.2.0 + product: coming 9.5, discontinued 9.7 + stack: ga 9.1 +""" + [] + let ``apply matches expected`` () = + markdown |> appliesTo (ApplicableTo( + Deployment=DeploymentApplicability( + Eck=ApplicabilityOverTime.op_Explicit "ga 9.0", + Ess=ApplicabilityOverTime.op_Explicit "beta 9.1", + Ece=ApplicabilityOverTime.op_Explicit "discontinued 9.2.0", + Self=ApplicabilityOverTime.op_Explicit "unavailable 9.3.0" + ), + Serverless=ServerlessProjectApplicability( + Security=ApplicabilityOverTime.op_Explicit "ga 9.0.0", + Elasticsearch=ApplicabilityOverTime.op_Explicit "beta 9.1.0", + Observability=ApplicabilityOverTime.op_Explicit "discontinued 9.2.0" + ), + Stack=ApplicabilityOverTime.op_Explicit "ga 9.1.0", + Product=ApplicabilityOverTime.op_Explicit "coming 9.5, discontinued 9.7" + )) diff --git a/tests/authoring/Blocks/CodeBlocks/CodeBlocks.fs b/tests/authoring/Blocks/CodeBlocks/CodeBlocks.fs index fb01f5f0e..287bdee02 100644 --- a/tests/authoring/Blocks/CodeBlocks/CodeBlocks.fs +++ b/tests/authoring/Blocks/CodeBlocks/CodeBlocks.fs @@ -4,8 +4,11 @@ module ``block elements``.``code blocks`` +open Elastic.Markdown.Myst.CodeBlocks +open Swensen.Unquote open Xunit open authoring +open authoring.MarkdownDocumentAssertions type ``warns on invalid language`` () = static let markdown = Setup.Markdown """ @@ -15,4 +18,9 @@ type ``warns on invalid language`` () = [] let ``validate HTML: generates link and alt attr`` () = - markdown |> hasWarning "Unknown language: not-a-valid-language" \ No newline at end of file + markdown |> hasWarning "Unknown language: not-a-valid-language" + + [] + let ``parses to EnhancedCodeBlock`` () = + let codeBlocks = markdown |> converts "index.md" |> parses + test <@ codeBlocks.Length = 1 @> diff --git a/tests/authoring/Framework/MarkdownDocumentAssertions.fs b/tests/authoring/Framework/MarkdownDocumentAssertions.fs index c5d3c3a77..781d5e8cb 100644 --- a/tests/authoring/Framework/MarkdownDocumentAssertions.fs +++ b/tests/authoring/Framework/MarkdownDocumentAssertions.fs @@ -5,7 +5,10 @@ namespace authoring open System.Diagnostics +open Elastic.Markdown.Myst.CodeBlocks +open Elastic.Markdown.Myst.FrontMatter open Markdig.Syntax +open Swensen.Unquote open Xunit.Sdk module MarkdownDocumentAssertions = @@ -22,4 +25,26 @@ module MarkdownDocumentAssertions = let unsupportedBlocks = actual.MinimalParse.Descendants<'element>() |> Array.ofSeq if unsupportedBlocks.Length = 0 then raise (XunitException($"Could not find {typedefof<'element>.Name} in minimally parsed document")) - unsupportedBlocks; + unsupportedBlocks + + [] + let appliesTo (expectedAvailability: ApplicableTo) (actual: Lazy) = + let actual = actual.Value + let result = actual.MarkdownResults |> Seq.head + let matter = result.File.YamlFrontMatter + match matter with + | NonNull m -> + let apply = m.AppliesTo + test <@ apply = expectedAvailability @> + | _ -> failwithf "%s has no yamlfront matter" result.File.RelativePath + + + [] + let appliesToDirective (expectedAvailability: ApplicableTo) (actual: AppliesToDirective array) = + let actual = actual |> Array.tryHead + match actual with + | Some d -> + let apply = d.AppliesTo + test <@ apply = expectedAvailability @> + | _ -> failwithf "Could not locate an AppliesToDirective" + diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index 6e05e70db..e0a8396d4 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -1,4 +1,4 @@ - + net9.0 @@ -60,4 +60,9 @@ + + + + +