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 @@
+