From 4b614a95d7c25697afa57c4f90a2ce2c4cd8b065 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 14 Jul 2025 21:10:54 +0200 Subject: [PATCH 1/4] Add support for version context variables --- docs/_docset.yml | 1 + docs/syntax/version-variables.md | 60 +++++++++++++++++++ .../BuildContext.cs | 2 +- .../Builder/ConfigurationFile.cs | 29 ++++++++- .../Versions/Version.cs | 6 +- .../Components/ApplicableToComponent.cshtml | 27 +-------- tests/authoring/Framework/Setup.fs | 12 ++-- 7 files changed, 102 insertions(+), 35 deletions(-) create mode 100644 docs/syntax/version-variables.md diff --git a/docs/_docset.yml b/docs/_docset.yml index 15273c6dd..0d3b24451 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -100,6 +100,7 @@ toc: - file: sidebars.md - file: stepper.md - file: substitutions.md + - file: version-variables.md - file: sundries.md - file: tables.md - file: tabs.md diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md new file mode 100644 index 000000000..87cc0cf07 --- /dev/null +++ b/docs/syntax/version-variables.md @@ -0,0 +1,60 @@ +# Version Variables + +Version are exposed during build using the `{{versions.VERSIONING_SCHEME}}` variable. + +For example `stack` versioning variables are exposed as `{{versions.stack}}`. + +## Specialized Suffixes. + +Besides the current version, the following suffixes are available: + +| Version substitution | result | purpose | +|--------------------------------------|-----------------------------------|-----------------------------------------| +| `{{versions.stack}}` | {{version.stack}} | Current version | +| `{{versions.stack.major_minor}}` | {{version.stack.major_minor}} | Current `MAJOR.MINOR` | +| `{{versions.stack.major_x}}` | {{version.stack.major_x}} | Current `MAJOR.X` | +| `{{versions.stack.major_component}}` | {{version.stack.major_component}} | Current major component | +| `{{versions.stack.next_major}}` | {{version.stack.next_major}} | The next major version | +| `{{versions.stack.next_minor}}` | {{version.stack.next_minor}} | The next minor version | +| `{{versions.stack.base}}` | {{version.stack.base}} | The first version on the new doc system | + + +## Available versioning schemes. + +This is dictated by the [version.yml](https://github.com/elastic/docs-builder/blob/main/src/Elastic.Documentation.Configuration/versions.yml) configuration file + +* `stack` +* `ece` +* `ech` +* `eck` +* `ess` +* `self` +* `ecctl` +* `curator` +* `security` +* `apm_agent_android` +* `apm_agent_ios` +* `apm_agent_dotnet` +* `apm_agent_go` +* `apm_agent_java` +* `apm_agent_node` +* `apm_agent_php` +* `apm_agent_python` +* `apm_agent_ruby` +* `apm_agent_rum` +* `edot_ios` +* `edot_android` +* `edot_dotnet` +* `edot_java` +* `edot_node` +* `edot_php` +* `edot_python` +* `edot_cf_aws` +* `edot_collector` + +The following are available but should not be used. These map to serverless projects and have a fixed high version number. + +* `all` +* `serverless` +* `elasticsearch` +* `observability` diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 29c69062b..154d90b79 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -100,7 +100,7 @@ public BuildContext( DocumentationSourceDirectory = ConfigurationPath.Directory!; Git = gitCheckoutInformation ?? GitCheckoutInformation.Create(DocumentationCheckoutDirectory, ReadFileSystem); - Configuration = new ConfigurationFile(this); + Configuration = new ConfigurationFile(this, VersionsConfig); GoogleTagManager = new GoogleTagManagerConfiguration { Enabled = false diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 47c2b320d..24cbaa5b8 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -6,6 +6,7 @@ using DotNet.Globbing; using Elastic.Documentation.Configuration.Suggestions; using Elastic.Documentation.Configuration.TableOfContents; +using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Links; using Elastic.Documentation.Navigation; using YamlDotNet.RepresentationModel; @@ -61,7 +62,7 @@ public record ConfigurationFile : ITableOfContentsScope Project is not null && Project.Equals("Elastic documentation", StringComparison.OrdinalIgnoreCase); - public ConfigurationFile(IDocumentationContext context) + public ConfigurationFile(IDocumentationContext context, VersionsConfiguration versionsConfig) { _context = context; ScopeDirectory = context.ConfigurationPath.Directory!; @@ -159,6 +160,32 @@ public ConfigurationFile(IDocumentationContext context) } } + foreach (var (id, system) in versionsConfig.VersioningSystems) + { + var name = id.ToStringFast(true); + var current = system.Current; + var key = $"version.{name}"; + _substitutions[key] = system.Current; + + key = $"version.{name}.base"; + _substitutions[key] = system.Base; + + key = $"version.{name}.major_minor"; + _substitutions[key] = $"{current.Major:N0}.{current.Minor:N0}"; + + key = $"version.{name}.major_x"; + _substitutions[key] = $"{current.Major:N0}.x"; + + key = $"version.{name}.major_component"; + _substitutions[key] = $"{current.Major:N0}"; + + key = $"version.{name}.next_minor"; + _substitutions[key] = new SemVersion(current.Major, current.Minor + 1, current.Patch, current.Prerelease, current.Metadata); + + key = $"version.{name}.next_major"; + _substitutions[key] = new SemVersion(current.Major + 1, current.Minor, current.Patch, current.Prerelease, current.Metadata); + } + var toc = new TableOfContentsConfiguration(this, sourceFile, ScopeDirectory, _context, 0, ""); TableOfContents = toc.TableOfContents; Files = toc.Files; diff --git a/src/Elastic.Documentation.Configuration/Versions/Version.cs b/src/Elastic.Documentation.Configuration/Versions/Version.cs index 78e911567..a1f6525c7 100644 --- a/src/Elastic.Documentation.Configuration/Versions/Version.cs +++ b/src/Elastic.Documentation.Configuration/Versions/Version.cs @@ -44,11 +44,11 @@ public enum VersioningSystemId [Display(Name = "serverless")] Serverless, [Display(Name = "elasticsearch")] - Elasticsearch, + ElasticsearchProject, [Display(Name = "observability")] - Observability, + ObservabilityProject, [Display(Name = "security")] - Security, + SecurityProject, [Display(Name = "apm_agent_android")] ApmAgentAndroid, [Display(Name = "apm_agent_ios")] diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml index 5309ec657..48dc7d437 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml @@ -76,7 +76,7 @@ @RenderProduct( "Serverless Elasticsearch", "Serverless Elasticsearch projects", - VersioningSystemId.Elasticsearch, + VersioningSystemId.ElasticsearchProject, appliesTo.Serverless.Elasticsearch ) } @@ -86,7 +86,7 @@ @RenderProduct( "Serverless Observability", "Serverless Observability projects", - VersioningSystemId.Observability, + VersioningSystemId.ObservabilityProject, appliesTo.Serverless.Observability ) } @@ -96,7 +96,7 @@ @RenderProduct( "Serverless Security", "Serverless Security projects", - VersioningSystemId.Security, + VersioningSystemId.SecurityProject, appliesTo.Serverless.Security ) } @@ -112,127 +112,106 @@ if (pa.Ecctl is not null) { @RenderProduct("ECCTL", "Elastic Cloud Control", VersioningSystemId.Ecctl, pa.Ecctl) - ; } if (pa.Curator is not null) { @RenderProduct("Curator", "Curator", VersioningSystemId.Curator, pa.Curator) - ; } if (pa.ApmAgentAndroid is not null) { @RenderProduct("APM Agent Android", "Application Performance Monitoring Agent for Android", VersioningSystemId.ApmAgentAndroid, pa.ApmAgentAndroid) - ; } if (pa.ApmAgentDotnet is not null) { @RenderProduct("APM Agent .NET", "Application Performance Monitoring Agent for .NET", VersioningSystemId.ApmAgentDotnet, pa.ApmAgentDotnet) - ; } if (pa.ApmAgentGo is not null) { @RenderProduct("APM Agent Go", "Application Performance Monitoring Agent for Go", VersioningSystemId.ApmAgentGo, pa.ApmAgentGo) - ; } if (pa.ApmAgentIos is not null) { @RenderProduct("APM Agent iOS", "Application Performance Monitoring Agent for iOS", VersioningSystemId.ApmAgentIos, pa.ApmAgentIos) - ; } if (pa.ApmAgentJava is not null) { @RenderProduct("APM Agent Java", "Application Performance Monitoring Agent for Java", VersioningSystemId.ApmAgentJava, pa.ApmAgentJava) - ; } if (pa.ApmAgentNode is not null) { @RenderProduct("APM Agent Node.js", "Application Performance Monitoring Agent for Node.js", VersioningSystemId.ApmAgentNode, pa.ApmAgentNode) - ; } if (pa.ApmAgentPhp is not null) { @RenderProduct("APM Agent PHP", "Application Performance Monitoring Agent for PHP", VersioningSystemId.ApmAgentPhp, pa.ApmAgentPhp) - ; } if (pa.ApmAgentPython is not null) { @RenderProduct("APM Agent Python", "Application Performance Monitoring Agent for Python", VersioningSystemId.ApmAgentPython, pa.ApmAgentPython) - ; } if (pa.ApmAgentRuby is not null) { @RenderProduct("APM Agent Ruby", "Application Performance Monitoring Agent for Ruby", VersioningSystemId.ApmAgentRuby, pa.ApmAgentRuby) - ; } if (pa.ApmAgentRum is not null) { @RenderProduct("APM Agent RUM", "Application Performance Monitoring Agent for Real User Monitoring", VersioningSystemId.ApmAgentRum, pa.ApmAgentRum) - ; } if (pa.EdotIos is not null) { @RenderProduct("EDOT iOS", "Elastic Distribution of OpenTelemetry iOS", VersioningSystemId.EdotIos, pa.EdotIos) - ; } if (pa.EdotAndroid is not null) { @RenderProduct("EDOT Android", "Elastic Distribution of OpenTelemetry Android", VersioningSystemId.EdotAndroid, pa.EdotAndroid) - ; } if (pa.EdotDotnet is not null) { @RenderProduct("EDOT .NET", "Elastic Distribution of OpenTelemetry .NET", VersioningSystemId.EdotDotnet, pa.EdotDotnet) - ; } if (pa.EdotJava is not null) { @RenderProduct("EDOT Java", "Elastic Distribution of OpenTelemetry Java", VersioningSystemId.EdotJava, pa.EdotJava) - ; } if (pa.EdotNode is not null) { @RenderProduct("EDOT Node.js", "Elastic Distribution of OpenTelemetry Node.js", VersioningSystemId.EdotNode, pa.EdotNode) - ; } if (pa.EdotPhp is not null) { @RenderProduct("EDOT PHP", "Elastic Distribution of OpenTelemetry PHP", VersioningSystemId.ApmAgentPhp, pa.EdotPhp) - ; } if (pa.EdotPython is not null) { @RenderProduct("EDOT Python", "Elastic Distribution of OpenTelemetry Python", VersioningSystemId.EdotPython, pa.EdotPython) - ; } if (pa.EdotCfAws is not null) { @RenderProduct("EDOT CF AWS", "Elastic Distribution of OpenTelemetry Cloud Forwarder for AWS", VersioningSystemId.EdotCfAws, pa.EdotCfAws) - ; } if (pa.EdotCollector is not null) { @RenderProduct("EDOT Collector", "Elastic Distribution of OpenTelemetry Collector", VersioningSystemId.EdotCollector, pa.EdotCollector) - ; } } diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 406f2dc8f..aa11457fe 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -167,9 +167,9 @@ type Setup = Base = SemVersion(8, 0, 0) ) ) - versioningSystems.Add(VersioningSystemId.Elasticsearch, + versioningSystems.Add(VersioningSystemId.ElasticsearchProject, VersioningSystem( - Id = VersioningSystemId.Elasticsearch, + Id = VersioningSystemId.ElasticsearchProject, Current = SemVersion(8, 0, 0), Base = SemVersion(8, 0, 0) ) @@ -188,16 +188,16 @@ type Setup = Base = SemVersion(8, 0, 0) ) ) - versioningSystems.Add(VersioningSystemId.Observability, + versioningSystems.Add(VersioningSystemId.ObservabilityProject, VersioningSystem( - Id = VersioningSystemId.Observability, + Id = VersioningSystemId.ObservabilityProject, Current = SemVersion(8, 0, 0), Base = SemVersion(8, 0, 0) ) ) - versioningSystems.Add(VersioningSystemId.Security, + versioningSystems.Add(VersioningSystemId.SecurityProject, VersioningSystem( - Id = VersioningSystemId.Security, + Id = VersioningSystemId.SecurityProject, Current = SemVersion(8, 0, 0), Base = SemVersion(8, 0, 0) ) From f5dae29a9654c60bf52a8ca2b358263252d23263 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 15 Jul 2025 09:37:17 +0200 Subject: [PATCH 2/4] Apply suggestion from @reakaleek Co-authored-by: Jan Calanog --- docs/syntax/version-variables.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md index 87cc0cf07..3a1bb6697 100644 --- a/docs/syntax/version-variables.md +++ b/docs/syntax/version-variables.md @@ -55,6 +55,9 @@ This is dictated by the [version.yml](https://github.com/elastic/docs-builder/bl The following are available but should not be used. These map to serverless projects and have a fixed high version number. * `all` +* `ech` +* `ess` (This is deprectated but was added for backwards-compatibility.) + * `serverless` * `elasticsearch` * `observability` From b3ea9860c4346b22aff7c623a49a66cc06e35209 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 15 Jul 2025 09:37:31 +0200 Subject: [PATCH 3/4] Apply suggestion from @reakaleek Co-authored-by: Jan Calanog --- docs/syntax/version-variables.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md index 3a1bb6697..2f0835f94 100644 --- a/docs/syntax/version-variables.md +++ b/docs/syntax/version-variables.md @@ -21,7 +21,7 @@ Besides the current version, the following suffixes are available: ## Available versioning schemes. -This is dictated by the [version.yml](https://github.com/elastic/docs-builder/blob/main/src/Elastic.Documentation.Configuration/versions.yml) configuration file +This is dictated by the [versions.yml](https://github.com/elastic/docs-builder/blob/main/src/Elastic.Documentation.Configuration/versions.yml) configuration file * `stack` * `ece` From 3fa4b3397af5234ed34d02b7eca7071e413a7bb2 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 15 Jul 2025 23:23:03 +0200 Subject: [PATCH 4/4] Add support for variable operator syntax (#1563) * Add support for variable operator syntax * do not report version variables as unused * Allow a space after {{ * carry trimming over to split * Temporarily make variables with spaces that are not found hints, we used to not do anything here * flip ternary --- docs/syntax/substitutions.md | 94 +++++++++++- docs/syntax/version-variables.md | 21 ++- .../Builder/ConfigurationFile.cs | 16 -- .../ProcessorDiagnosticExtensions.cs | 28 ++-- .../DocumentationGenerator.cs | 8 +- .../Substitution/SubstitutionParser.cs | 137 +++++++++++++++++- .../authoring/Inline/SubstitutionMutations.fs | 52 +++++++ tests/authoring/Inline/Substitutions.fs | 5 +- tests/authoring/authoring.fsproj | 1 + 9 files changed, 311 insertions(+), 51 deletions(-) create mode 100644 tests/authoring/Inline/SubstitutionMutations.fs diff --git a/docs/syntax/substitutions.md b/docs/syntax/substitutions.md index d7bd928d1..45adc889f 100644 --- a/docs/syntax/substitutions.md +++ b/docs/syntax/substitutions.md @@ -3,6 +3,7 @@ sub: frontmatter_key: "Front Matter Value" a-key-with-dashes: "A key with dashes" version: 7.17.0 + hello-world: "Hello world!" --- # Substitutions @@ -26,7 +27,7 @@ Doing so will result in a build error. To use the variables in your files, surround them in curly brackets (`{{variable}}`). -## Example +### Example Here are some variable substitutions: @@ -36,6 +37,97 @@ Here are some variable substitutions: | {{a-key-with-dashes}} | Front Matter | | {{a-global-variable}} | `docset.yml` | +## Mutations + +Substitutions can be mutated using a chain of operators seperated by a pipe (`|`). + +````markdown +`{{hello-world | trim | lc | tc}}` +```` + +Will trim, lowercase and finally titlecase the contents of the 'hello-world' variable. + +### Operators + + +| Operator | Purpose | +|----------|----------------------------------------------------| +| `lc` | LowerCase, | +| `uc` | UpperCase, | +| `tc` | TitleCase, capitalizes all words, | +| `c` | Capitalize the first letter, | +| `kc` | Convert to KebabCase, | +| `sc` | Convert to SnakeCase, | +| `cc` | Convert to CamelCase, | +| `pc` | Convert to PascalCase, | +| `trim` | Trim common non word characters from start and end | + +For variables declaring a semantic version or `Major.Minor` the following operations are also exposed + +| Operator | Purpose | +|----------|------------------------------------------| +| `M` | Display only the major component | +| `M.x` | Display major component followed by '.x' | +| `M.M` | Display only the major and the minor | +| `M+1` | The next major version | +| `M.M+1` | The next minor version | + +### Example + +Given the following frontmatter: + +```yaml +--- +sub: + hello-world: "Hello world!" +--- +``` + +::::{tab-set} + +:::{tab-item} Output + +* Lowercase: {{hello-world | lc}} +* Uppercase: {{hello-world | uc}} +* TitleCase: {{hello-world | tc}} +* kebab-case: {{hello-world | kc}} +* camelCase: {{hello-world | tc | cc}} +* PascalCase: {{hello-world | pc}} +* SnakeCase: {{hello-world | sc}} +* CapitalCase (chained): {{hello-world | lc | c}} +* Trim: {{hello-world | trim}} +* M.x: {{version.stack | M.x }} +* M.M: {{version.stack | M.M }} +* M: {{version.stack | M }} +* M+1: {{version.stack | M+1 }} +* M+1 | M.M: {{version.stack | M+1 | M.M }} +* M.M+1: {{version.stack | M.M+1 }} + +::: + +:::{tab-item} Markdown + +````markdown +* Lowercase: {{hello-world | lc}} +* Uppercase: {{hello-world | uc}} +* TitleCase: {{hello-world | tc}} +* kebab-case: {{hello-world | kc}} +* camelCase: {{hello-world | tc | cc}} +* PascalCase: {{hello-world | pc}} +* SnakeCase: {{hello-world | sc}} +* CapitalCase (chained): {{hello-world | lc | c}} +* Trim: {{hello-world | trim}} +* M.x: {{version.stack | M.x }} +* M.M: {{version.stack | M.M }} +* M: {{version.stack | M }} +* M+1: {{version.stack | M+1 }} +* M+1 | M.M: {{version.stack | M+1 | M.M }} +* M.M+1: {{version.stack | M.M+1 }} +```` +::: + +:::: + ## Code blocks Substitutions are supported in code blocks but are disabled by default. Enable substitutions by adding `subs=true` to the code block. diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md index 2f0835f94..fc852ad1e 100644 --- a/docs/syntax/version-variables.md +++ b/docs/syntax/version-variables.md @@ -10,14 +10,21 @@ Besides the current version, the following suffixes are available: | Version substitution | result | purpose | |--------------------------------------|-----------------------------------|-----------------------------------------| -| `{{versions.stack}}` | {{version.stack}} | Current version | -| `{{versions.stack.major_minor}}` | {{version.stack.major_minor}} | Current `MAJOR.MINOR` | -| `{{versions.stack.major_x}}` | {{version.stack.major_x}} | Current `MAJOR.X` | -| `{{versions.stack.major_component}}` | {{version.stack.major_component}} | Current major component | -| `{{versions.stack.next_major}}` | {{version.stack.next_major}} | The next major version | -| `{{versions.stack.next_minor}}` | {{version.stack.next_minor}} | The next minor version | -| `{{versions.stack.base}}` | {{version.stack.base}} | The first version on the new doc system | +| `{{version.stack}}` | {{version.stack}} | Current version | +| `{{version.stack.base}}` | {{version.stack.base}} | The first version on the new doc system | +## Formatting + +Using specialized [mutation operators](substitutions.md#mutations) versions +can be printed in any kind of ways. + + +| Version substitution | result | +|------------------------|-----------| +| `{{version.stack| M.M}}` | {{version.stack|M.M}} | +| `{{version.stack.base | M }}` | {{version.stack.base | M }} | +| `{{version.stack | M+1 | M }}` | {{version.stack | M+1 | M }} | +| `{{version.stack.base | M.M+1 }}` | {{version.stack.base | M.M+1 }} | ## Available versioning schemes. diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 24cbaa5b8..da4cb39de 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -163,27 +163,11 @@ public ConfigurationFile(IDocumentationContext context, VersionsConfiguration ve foreach (var (id, system) in versionsConfig.VersioningSystems) { var name = id.ToStringFast(true); - var current = system.Current; var key = $"version.{name}"; _substitutions[key] = system.Current; key = $"version.{name}.base"; _substitutions[key] = system.Base; - - key = $"version.{name}.major_minor"; - _substitutions[key] = $"{current.Major:N0}.{current.Minor:N0}"; - - key = $"version.{name}.major_x"; - _substitutions[key] = $"{current.Major:N0}.x"; - - key = $"version.{name}.major_component"; - _substitutions[key] = $"{current.Major:N0}"; - - key = $"version.{name}.next_minor"; - _substitutions[key] = new SemVersion(current.Major, current.Minor + 1, current.Patch, current.Prerelease, current.Metadata); - - key = $"version.{name}.next_major"; - _substitutions[key] = new SemVersion(current.Major + 1, current.Minor, current.Patch, current.Prerelease, current.Metadata); } var toc = new TableOfContentsConfiguration(this, sourceFile, ScopeDirectory, _context, 0, ""); diff --git a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs index 24e43c434..abcefd00d 100644 --- a/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs +++ b/src/Elastic.Markdown/Diagnostics/ProcessorDiagnosticExtensions.cs @@ -14,32 +14,24 @@ 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(); - if (context.SkipValidation) - return; - var d = new Diagnostic - { - Severity = Severity.Error, - File = processor.GetContext().MarkdownSourcePath.FullName, - Column = column, - Line = line, - Message = message, - Length = length - }; - context.Build.Collector.Write(d); - } + public static void EmitError(this InlineProcessor processor, int line, int column, int length, string message) => + processor.Emit(Severity.Error, line, column, length, message); - public static void EmitWarning(this InlineProcessor processor, int line, int column, int length, string message) + public static void EmitWarning(this InlineProcessor processor, int line, int column, int length, string message) => + processor.Emit(Severity.Warning, line, column, length, message); + + public static void EmitHint(this InlineProcessor processor, int line, int column, int length, string message) => + processor.Emit(Severity.Hint, line, column, length, message); + + public static void Emit(this InlineProcessor processor, Severity severity, int line, int column, int length, string message) { var context = processor.GetContext(); if (context.SkipValidation) return; var d = new Diagnostic { - Severity = Severity.Warning, + Severity = severity, File = processor.GetContext().MarkdownSourcePath.FullName, Column = column, Line = line, diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 3a9bd9eed..b956771ea 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -180,7 +180,13 @@ private void HintUnusedSubstitutionKeys() { var definedKeys = new HashSet(Context.Configuration.Substitutions.Keys.ToArray()); var inUse = new HashSet(Context.Collector.InUseSubstitutionKeys.Keys); - var keysNotInUse = definedKeys.Except(inUse).ToArray(); + var keysNotInUse = definedKeys.Except(inUse) + // versions keys are injected + .Where(key => !key.StartsWith("version.")) + // reserving context namespace + .Where(key => !key.StartsWith("context.")) + .ToArray(); + // If we have less than 20 unused keys, emit them separately, // Otherwise emit one hint with all of them for brevity if (keysNotInUse.Length >= 20) diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 34c20295a..7b0f33e67 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -3,7 +3,13 @@ // See the LICENSE file in the project root for more information using System.Buffers; +using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using Elastic.Documentation; +using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Diagnostics; using Markdig.Helpers; using Markdig.Parsers; @@ -11,27 +17,117 @@ using Markdig.Renderers.Html; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using NetEscapades.EnumGenerators; 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) +public class SubstitutionLeaf(string content, bool found, string replacement) + : CodeInline(content) { public bool Found { get; } = found; public string Replacement { get; } = replacement; + public IReadOnlyCollection? Mutations { get; set; } +} + +[EnumExtensions] +public enum SubstitutionMutation +{ + [Display(Name = "M")] MajorComponent, + [Display(Name = "M.x")] MajorX, + [Display(Name = "M.M")] MajorMinor, + [Display(Name = "M+1")] IncreaseMajor, + [Display(Name = "M.M+1")] IncreaseMinor, + [Display(Name = "lc")] LowerCase, + [Display(Name = "uc")] UpperCase, + [Display(Name = "tc")] TitleCase, + [Display(Name = "c")] Capitalize, + [Display(Name = "kc")] KebabCase, + [Display(Name = "sc")] SnakeCase, + [Display(Name = "cc")] CamelCase, + [Display(Name = "pc")] PascalCase, + [Display(Name = "trim")] Trim } public class SubstitutionRenderer : HtmlObjectRenderer { - protected override void Write(HtmlRenderer renderer, SubstitutionLeaf obj) => - renderer.Write(obj.Found ? obj.Replacement : obj.Content); + protected override void Write(HtmlRenderer renderer, SubstitutionLeaf leaf) + { + if (!leaf.Found) + { + _ = renderer.Write(leaf.Content); + return; + } + + var replacement = leaf.Replacement; + if (leaf.Mutations is null or { Count: 0 }) + { + _ = renderer.Write(replacement); + return; + } + + foreach (var mutation in leaf.Mutations) + { + var (success, update) = mutation switch + { + SubstitutionMutation.MajorComponent => TryGetVersion(replacement, v => $"{v.Major}"), + SubstitutionMutation.MajorX => TryGetVersion(replacement, v => $"{v.Major}.x"), + SubstitutionMutation.MajorMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor}"), + SubstitutionMutation.IncreaseMajor => TryGetVersion(replacement, v => $"{v.Major + 1}.0.0"), + SubstitutionMutation.IncreaseMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor + 1}.0"), + SubstitutionMutation.LowerCase => (true, replacement.ToLowerInvariant()), + SubstitutionMutation.UpperCase => (true, replacement.ToUpperInvariant()), + SubstitutionMutation.Capitalize => (true, Capitalize(replacement)), + SubstitutionMutation.KebabCase => (true, ToKebabCase(replacement)), + SubstitutionMutation.CamelCase => (true, ToCamelCase(replacement)), + SubstitutionMutation.PascalCase => (true, ToPascalCase(replacement)), + SubstitutionMutation.SnakeCase => (true, ToSnakeCase(replacement)), + SubstitutionMutation.TitleCase => (true, TitleCase(replacement)), + SubstitutionMutation.Trim => (true, Trim(replacement)), + _ => throw new Exception($"encountered an unknown mutation '{mutation.ToStringFast(true)}'") + }; + if (!success) + { + _ = renderer.Write(leaf.Content); + return; + } + replacement = update; + } + _ = renderer.Write(replacement); + } + + private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str.Replace(" ", string.Empty)); + private static string ToSnakeCase(string str) => JsonNamingPolicy.SnakeCaseLower.ConvertName(str).Replace(" ", string.Empty); + private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty); + private static string ToPascalCase(string str) => TitleCase(str).Replace(" ", string.Empty); + + private static string TitleCase(string str) => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(str); + + private static string Trim(string str) => + str.AsSpan().Trim(['!', ' ', '\t', '\r', '\n', '.', ',', ')', '(', ':', ';', '<', '>', '[', ']']).ToString(); + + private static string Capitalize(string input) => + input switch + { + null => string.Empty, + "" => string.Empty, + _ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1)) + }; + + private (bool, string) TryGetVersion(string version, Func mutate) + { + if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v)) + return (false, string.Empty); + + return (true, mutate(v)); + } } public class SubstitutionParser : InlineParser { public SubstitutionParser() => OpeningCharacters = ['{']; - private readonly SearchValues _values = SearchValues.Create(['\r', '\n', ' ', '\t', '}']); + private readonly SearchValues _values = SearchValues.Create(['\r', '\n', '\t', '}']); public override bool Match(InlineProcessor processor, ref StringSlice slice) { @@ -81,9 +177,13 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) startPosition -= openSticks; startPosition = Math.Max(startPosition, 0); - var key = content.ToString().Trim(['{', '}']).ToLowerInvariant(); + var key = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant(); var found = false; var replacement = string.Empty; + var components = key.Split('|'); + if (components.Length > 1) + key = components[0].Trim(['{', '}']).Trim().ToLowerInvariant(); + if (context.Substitutions.TryGetValue(key, out var value)) { found = true; @@ -100,7 +200,6 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) var start = processor.GetSourcePosition(startPosition, out var line, out var column); var end = processor.GetSourcePosition(slice.Start); var sourceSpan = new SourceSpan(start, end); - var substitutionLeaf = new SubstitutionLeaf(content.ToString(), found, replacement) { Delimiter = '{', @@ -109,8 +208,32 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) Column = column, DelimiterCount = openSticks }; + if (!found) - processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} is undefined"); + // We temporarily diagnose variable spaces as hints. We used to not read this at all. + processor.Emit(key.Contains(' ') ? Severity.Hint : Severity.Error, line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} is undefined"); + else + { + List? mutations = null; + if (components.Length >= 10) + processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} defines too many mutations, none will be applied"); + else if (components.Length > 1) + { + foreach (var c in components[1..]) + { + if (SubstitutionMutationExtensions.TryParse(c.Trim(), out var mutation, true, true)) + { + mutations ??= []; + mutations.Add(mutation); + } + else + processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{c}' on {{{key}}} is undefined"); + } + } + + substitutionLeaf.Mutations = mutations; + } + if (processor.TrackTrivia) { diff --git a/tests/authoring/Inline/SubstitutionMutations.fs b/tests/authoring/Inline/SubstitutionMutations.fs new file mode 100644 index 000000000..6929efeff --- /dev/null +++ b/tests/authoring/Inline/SubstitutionMutations.fs @@ -0,0 +1,52 @@ +// 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``.``substitution mutations`` + +open Xunit +open authoring + +type ``read sub from yaml frontmatter`` () = + let markdown = Setup.Document """--- +sub: + hello-world: "Hello world!" + versions.stack: 9.1.0 +--- +* Lowercase: {{hello-world | lc}} +* Uppercase: {{hello-world | uc}} +* TitleCase: {{hello-world | tc}} +* kebab-case: {{hello-world | kc}} +* camelCase: {{hello-world | tc | cc}} +* PascalCase: {{hello-world | pc}} +* SnakeCase: {{hello-world | sc}} +* CapitalCase (chained): {{hello-world | lc | c}} +* Trim: {{hello-world | trim}} +* M.x: {{versions.stack | M.x }} +* M.M: {{versions.stack | M.M }} +* M: {{versions.stack | M }} +* M+1: {{versions.stack | M+1 }} +* M+1 | M.M: {{versions.stack | M+1 | M.M }} +* M.M+1: {{versions.stack | M.M+1 }} +""" + + [] + let ``validate HTML: replace substitution`` () = + markdown |> convertsToHtml """
    +
  • Lowercase: hello world!
  • +
  • Uppercase: HELLO WORLD!
  • +
  • TitleCase: Hello World!
  • +
  • kebab-case: hello-world!
  • +
  • camelCase: helloWorld!
  • +
  • PascalCase: HelloWorld!
  • +
  • SnakeCase: hello_world!
  • +
  • CapitalCase (chained): Hello world!
  • +
  • Trim: Hello world
  • +
  • M.x: 9.x
  • +
  • M.M: 9.1
  • +
  • M: 9
  • +
  • M+1: 10.0.0
  • +
  • M+1 | M.M: 10.0
  • +
  • M.M+1: 9.2.0
  • +
+ """ diff --git a/tests/authoring/Inline/Substitutions.fs b/tests/authoring/Inline/Substitutions.fs index ed75ba22b..1a7679952 100644 --- a/tests/authoring/Inline/Substitutions.fs +++ b/tests/authoring/Inline/Substitutions.fs @@ -46,6 +46,7 @@ The following should be subbed: {{hello-world}} not a comment not a {{valid-key}} not a {substitution} +The following should be subbed too: {{ hello-world }} """ [] @@ -58,5 +59,7 @@ not a {substitution}

The following should be subbed: Hello World! not a comment not a {{valid-key}} - not a {substitution}

+ not a {substitution} + The following should be subbed too: Hello World! +

""" diff --git a/tests/authoring/authoring.fsproj b/tests/authoring/authoring.fsproj index a58fafc22..a0b659cb4 100644 --- a/tests/authoring/authoring.fsproj +++ b/tests/authoring/authoring.fsproj @@ -40,6 +40,7 @@ +