Skip to content

Commit 66a7abb

Browse files
authored
Add subs support for inline code snippets (#1705)
* Add inline subs * Reorder docs * Edit docs * Rename to role * Delete demo file
1 parent 809a2f5 commit 66a7abb

File tree

5 files changed

+246
-29
lines changed

5 files changed

+246
-29
lines changed

docs/syntax/substitutions.md

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ sub:
44
a-key-with-dashes: "A key with dashes"
55
version: 7.17.0
66
hello-world: "Hello world!"
7+
env-var: "MY_VAR"
78
---
89

910
# Substitutions
@@ -25,9 +26,13 @@ subs:
2526
If a substitution is defined globally it may not be redefined (shaded) in a files `frontmatter`.
2627
Doing so will result in a build error.
2728

28-
To use the variables in your files, surround them in curly brackets (`{{variable}}`).
29+
To use the variables in your files, surround them in curly brackets (`{{variable}}`). Substitutions work in:
2930

30-
### Example
31+
- Regular text content
32+
- Code blocks (when `subs=true` is specified)
33+
- Inline code snippets (when `{subs}` prefix is used)
34+
35+
## Example
3136

3237
Here are some variable substitutions:
3338

@@ -170,50 +175,45 @@ cd elasticsearch-{{version}}/
170175
::::
171176

172177

173-
### MD code block with subs enabled
178+
## Inline code
174179

175-
::::{tab-set}
180+
Substitutions are also supported in inline code snippets using the `{subs}` syntax.
176181

177-
:::{tab-item} Output
178-
179-
```bash subs=true
180-
echo "{{a-global-variable}}"
182+
```markdown
183+
{subs}`wget elasticsearch-{{version.stack}}.tar.gz`
181184
```
182185

183-
:::
184-
185-
:::{tab-item} Markdown
186+
### Inline code examples
186187

187-
````markdown
188-
```bash subs=true
189-
echo "{{a-global-variable}}"
190-
```
188+
::::{tab-set}
191189

192-
````
193-
:::
194-
195-
::::
190+
:::{tab-item} Output
196191

197-
### MD code block without subs enabled
192+
Regular inline code: `wget elasticsearch-{{version.stack}}.tar.gz`
198193

199-
::::{tab-set}
194+
With substitutions: {subs}`wget elasticsearch-{{version.stack}}.tar.gz`
200195

201-
:::{tab-item} Output
196+
Multiple variables: {subs}`export {{env-var}}={{version.stack}}`
202197

203-
```bash
204-
echo "{{a-global-variable}}"
205-
```
198+
With mutations: {subs}`version {{version.stack | M.M}}`
206199

207200
:::
208201

209202
:::{tab-item} Markdown
210203

211204
````markdown
212-
```bash
213-
echo "{{a-global-variable}}"
214-
```
205+
Regular inline code: `wget elasticsearch-{{version.stack}}.tar.gz`
215206

207+
With substitutions: {subs=true}`wget elasticsearch-{{version.stack}}.tar.gz`
208+
209+
Multiple variables: {subs=true}`export {{env-var}}={{version.stack}}`
210+
211+
With mutations: {subs=true}`version {{version.stack | M.M}}`
216212
````
217-
:::
218213

214+
:::
219215
::::
216+
217+
:::{note}
218+
Regular inline code (without the `{subs=true}` prefix) will not process substitutions and will display the variable placeholders as-is.
219+
:::
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Markdig;
6+
using Markdig.Parsers.Inlines;
7+
using Markdig.Renderers;
8+
using Markdig.Renderers.Html.Inlines;
9+
10+
namespace Elastic.Markdown.Myst.InlineParsers.SubstitutionInlineCode;
11+
12+
public static class SubstitutionInlineCodeBuilderExtensions
13+
{
14+
public static MarkdownPipelineBuilder UseSubstitutionInlineCode(this MarkdownPipelineBuilder pipeline)
15+
{
16+
pipeline.Extensions.AddIfNotAlready<SubstitutionInlineCodeMarkdownExtension>();
17+
return pipeline;
18+
}
19+
}
20+
21+
public class SubstitutionInlineCodeMarkdownExtension : IMarkdownExtension
22+
{
23+
public void Setup(MarkdownPipelineBuilder pipeline)
24+
{
25+
if (!pipeline.InlineParsers.Contains<SubstitutionInlineCodeParser>())
26+
{
27+
// Insert before CodeInlineParser to intercept {subs=true}`...` patterns
28+
_ = pipeline.InlineParsers.InsertBefore<CodeInlineParser>(new SubstitutionInlineCodeParser());
29+
}
30+
}
31+
32+
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
33+
{
34+
if (!renderer.ObjectRenderers.Contains<SubstitutionInlineCodeRenderer>())
35+
_ = renderer.ObjectRenderers.InsertBefore<CodeInlineRenderer>(new SubstitutionInlineCodeRenderer());
36+
}
37+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Buffers;
6+
using System.Diagnostics;
7+
using System.Text;
8+
using System.Text.RegularExpressions;
9+
using Elastic.Documentation;
10+
using Elastic.Documentation.Diagnostics;
11+
using Elastic.Markdown.Diagnostics;
12+
using Elastic.Markdown.Myst.InlineParsers.Substitution;
13+
using Elastic.Markdown.Myst.Roles;
14+
using Markdig.Helpers;
15+
using Markdig.Parsers;
16+
using Markdig.Renderers;
17+
using Markdig.Renderers.Html;
18+
using Markdig.Syntax;
19+
using Markdig.Syntax.Inlines;
20+
21+
namespace Elastic.Markdown.Myst.InlineParsers.SubstitutionInlineCode;
22+
23+
[DebuggerDisplay("{GetType().Name} Line: {Line}, Content: {Content}, ProcessedContent: {ProcessedContent}")]
24+
public class SubstitutionInlineCodeLeaf(string role, string content, string processedContent) : RoleLeaf(role, content)
25+
{
26+
public string ProcessedContent { get; } = processedContent;
27+
}
28+
29+
public class SubstitutionInlineCodeRenderer : HtmlObjectRenderer<SubstitutionInlineCodeLeaf>
30+
{
31+
protected override void Write(HtmlRenderer renderer, SubstitutionInlineCodeLeaf leaf)
32+
{
33+
// Render as a code element with the processed content (substitutions applied)
34+
_ = renderer.Write("<code");
35+
_ = renderer.WriteAttributes(leaf);
36+
_ = renderer.Write(">");
37+
_ = renderer.WriteEscape(leaf.ProcessedContent);
38+
_ = renderer.Write("</code>");
39+
}
40+
}
41+
42+
public partial class SubstitutionInlineCodeParser : RoleParser<SubstitutionInlineCodeLeaf>
43+
{
44+
protected override SubstitutionInlineCodeLeaf CreateRole(string role, string content, InlineProcessor parserContext)
45+
{
46+
var context = (ParserContext)parserContext.Context!;
47+
var processedContent = ProcessSubstitutions(content, context, parserContext, 0, 0);
48+
return new SubstitutionInlineCodeLeaf(role, content, processedContent);
49+
}
50+
51+
protected override bool Matches(ReadOnlySpan<char> role) => role.SequenceEqual("{subs}".AsSpan());
52+
53+
private static readonly Regex SubstitutionPattern = SubstitutionRegex();
54+
55+
private static string ProcessSubstitutions(string content, ParserContext context, InlineProcessor processor, int line, int column)
56+
{
57+
var result = new StringBuilder(content);
58+
var substitutions = new List<(int Start, int Length, string Replacement)>();
59+
60+
// Find all substitution patterns
61+
foreach (Match match in SubstitutionPattern.Matches(content))
62+
{
63+
var rawKey = match.Groups[1].Value.Trim().ToLowerInvariant();
64+
var found = false;
65+
var replacement = string.Empty;
66+
67+
// Use shared mutation parsing logic
68+
var (key, mutationStrings) = SubstitutionMutationHelper.ParseKeyWithMutations(rawKey);
69+
70+
if (context.Substitutions.TryGetValue(key, out var value) && value is not null)
71+
{
72+
found = true;
73+
replacement = value;
74+
}
75+
else if (context.ContextSubstitutions.TryGetValue(key, out value) && value is not null)
76+
{
77+
found = true;
78+
replacement = value;
79+
}
80+
81+
if (found)
82+
{
83+
context.Build.Collector.CollectUsedSubstitutionKey(key);
84+
85+
// Apply mutations if any
86+
if (mutationStrings.Length > 0)
87+
{
88+
if (mutationStrings.Length >= 10)
89+
{
90+
processor.EmitError(line + 1, column + match.Index, match.Length, $"Substitution key {{{key}}} defines too many mutations, none will be applied");
91+
replacement = value; // Use original value without mutations
92+
}
93+
else
94+
{
95+
var mutations = new List<SubstitutionMutation>();
96+
foreach (var mutationStr in mutationStrings)
97+
{
98+
var trimmedMutation = mutationStr.Trim();
99+
if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true))
100+
{
101+
mutations.Add(mutation);
102+
}
103+
else
104+
{
105+
processor.EmitError(line + 1, column + match.Index, match.Length, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined");
106+
}
107+
}
108+
109+
if (mutations.Count > 0)
110+
replacement = SubstitutionMutationHelper.ApplyMutations(replacement, mutations);
111+
}
112+
}
113+
114+
substitutions.Add((match.Index, match.Length, replacement ?? string.Empty));
115+
}
116+
else
117+
{
118+
// We temporarily diagnose variable spaces as hints. We used to not read this at all.
119+
processor.Emit(key.Contains(' ') ? Severity.Hint : Severity.Error, line + 1, column + match.Index, match.Length, $"Substitution key {{{key}}} is undefined");
120+
}
121+
}
122+
123+
// Apply substitutions in reverse order to maintain correct indices
124+
foreach (var (start, length, replacement) in substitutions.OrderByDescending(s => s.Start))
125+
{
126+
_ = result.Remove(start, length);
127+
_ = result.Insert(start, replacement);
128+
}
129+
130+
return result.ToString();
131+
}
132+
133+
[GeneratedRegex(@"\{\{([^}]+)\}\}", RegexOptions.Compiled)]
134+
private static partial Regex SubstitutionRegex();
135+
}

src/Elastic.Markdown/Myst/MarkdownParser.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Elastic.Markdown.Myst.FrontMatter;
1313
using Elastic.Markdown.Myst.InlineParsers;
1414
using Elastic.Markdown.Myst.InlineParsers.Substitution;
15+
using Elastic.Markdown.Myst.InlineParsers.SubstitutionInlineCode;
1516
using Elastic.Markdown.Myst.Linters;
1617
using Elastic.Markdown.Myst.Renderers;
1718
using Elastic.Markdown.Myst.Roles.AppliesTo;
@@ -154,6 +155,7 @@ public static MarkdownPipeline Pipeline
154155
.UseDiagnosticLinks()
155156
.UseHeadingsWithSlugs()
156157
.UseEmphasisExtras(EmphasisExtraOptions.Default)
158+
.UseSubstitutionInlineCode()
157159
.UseInlineAppliesTo()
158160
.UseInlineIcons()
159161
.UseInlineKbd()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using FluentAssertions;
6+
7+
namespace Elastic.Markdown.Tests.Inline;
8+
9+
public class SubstitutionInlineCodeTest(ITestOutputHelper output) : InlineTest(output,
10+
"""
11+
---
12+
sub:
13+
version: "8.15.0"
14+
env-var: "MY_VAR"
15+
---
16+
17+
# Testing inline code substitutions
18+
19+
Regular code: `wget elasticsearch-{{version}}.tar.gz`
20+
21+
Code with substitutions: {subs}`wget elasticsearch-{{version}}.tar.gz`
22+
23+
Multiple substitutions: {subs}`export {{env-var}}={{version}}`
24+
25+
With mutations: {subs}`version {{version | M.M}}`
26+
"""
27+
)
28+
{
29+
[Fact]
30+
public void TestSubstitutionInlineCode()
31+
{
32+
// Check that regular code blocks are not processed
33+
Html.Should().Contain("<code>wget elasticsearch-{{version}}.tar.gz</code>");
34+
35+
// Check that {subs} inline code blocks have substitutions applied
36+
Html.Should().Contain("<code>wget elasticsearch-8.15.0.tar.gz</code>");
37+
Html.Should().Contain("<code>export MY_VAR=8.15.0</code>");
38+
Html.Should().Contain("<code>version 8.15</code>");
39+
}
40+
41+
[Fact]
42+
public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0);
43+
}

0 commit comments

Comments
 (0)