Skip to content

Commit ad72fdf

Browse files
Add support for expressive code in snippets (#717)
* Add support for expressive code blocks in snippets This update introduces the handling of expressive code metadata for code snippets, enabling enhanced representation and functionality. Changes include parsing, storing, and rendering expressive attributes in snippets, along with updates to tests and relevant components to validate the new feature. Additionally, a package dependency (Argon) is updated for compatibility. Only works for comment snippets currently. * Add ExpressiveCode class and integrate Pattern handling Introduced the `ExpressiveCode` class to centralize regex pattern handling, supporting both .NET 8.0+ generated regex and static regex for older versions. Updated `StartEndTester` to utilize the shared `ExpressiveCode.Instance.Pattern` for consistency and maintainability. Expanded documentation with examples of snippet metadata and its rendering. * Refactor Regex initialization with conditional compilation. Reorganized Regex initialization using `#if NET8_0_OR_GREATER` to leverage `GeneratedRegex` for .NET 8+ and fallback to static initialization otherwise instance is null. * Refactor snippet language declaration handling. Simplified the logic for generating the language declaration by checking for empty `ExpressiveCode`. No more TrimEnd.
1 parent abef72a commit ad72fdf

19 files changed

Lines changed: 196 additions & 38 deletions

readme.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,50 @@ Windows Registry Editor Version 5.00
366366
<sup><a href='/src/context-menu.reg#L1-L13' title='Snippet source file'>snippet source</a> | <a href='#snippet-context-menu.reg' title='Start of snippet'>anchor</a></sup>
367367
<!-- endSnippet -->
368368

369+
## Expressive Code / Snippet Metadata
370+
371+
When defining snippets, you can add additional metadata at the source to the rendered snippet using the following syntax.
372+
373+
```csharp
374+
// begin-snippet: HelloWorld(title=Program.cs {1})
375+
Console.WriteLine("Hello, World");
376+
// end-snippet
377+
```
378+
379+
Note the text within the parenthesis; this is metadata we want to add to the rendered Markdown block immediately after the language declaration.
380+
The metadata is stripped and the key remains `HelloWorld`. The feature produces the following output at your target destination (will vary based on your configuration):
381+
382+
````markdown
383+
<-- begin-snippet: HelloWorld -->
384+
```csharp title=Program.cs {1}
385+
Console.WriteLine("Hello, World");
386+
```
387+
<-- end-snippet -->
388+
````
389+
390+
This syntax is known as [Expressive Code](https://expressive-code.com/) and is supported in documentation systems such
391+
as [Astro Starlight](https://github.com/withastro/starlight/) but can be installed in any Markdown-powered tool
392+
that supports [reHype](https://github.com/rehypejs/rehype).
393+
394+
It is important to note, the metadata is not explicitly limited to Expressive code. Any text within the `()` will be rendered after
395+
the language block. This can be useful for adding additional information based on your specific rendering engine. For example, if
396+
you use a presentation tool such as [Sli.dev](https://sli.dev/), you can use this feature to apply [magic-move animations](https://sli.dev/features/shiki-magic-move).
397+
398+
```csharp
399+
// begin-snippet: EncapsulateVariable({*|2})
400+
Console.WriteLine("Hello, World");
401+
// end-snippet
402+
```
403+
404+
The above snippet will render as follows:
405+
406+
````markdown
407+
<-- begin-snippet: EncapsulateVariable -->
408+
```csharp {*|2}
409+
Console.WriteLine("Hello, World");
410+
```
411+
<-- end-snippet -->
412+
````
369413

370414
## More Documentation
371415

src/MarkdownSnippets/Processing/MarkdownProcessor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ Snippet FileToSnippet(string key, string file, string? path)
353353
value: text,
354354
key: key,
355355
language: Path.GetExtension(file)[1..],
356-
path: path);
356+
path: path,
357+
expressiveCode: null);
357358
}
358359

359360
(string text, int lineCount) ReadNonStartEndLines(string file)

src/MarkdownSnippets/Processing/SimpleSnippetMarkdownHandling.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ public static void Append(string key, IEnumerable<Snippet> snippets, Action<stri
1515

1616
static void WriteSnippet(Action<string> appendLine, Snippet snippet)
1717
{
18-
appendLine($"```{snippet.Language}");
18+
var declaration =
19+
string.IsNullOrWhiteSpace(snippet.ExpressiveCode)
20+
? snippet.Language
21+
: $"{snippet.Language} {snippet.ExpressiveCode}";
22+
appendLine($"```{declaration}");
1923
appendLine(snippet.Value);
2024
appendLine("```");
2125
}

src/MarkdownSnippets/Processing/SnippetMarkdownHandling.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ string GetSupText(Snippet snippet, string anchor)
8686

8787
static void WriteSnippetValueAndLanguage(Action<string> appendLine, Snippet snippet)
8888
{
89-
appendLine($"```{snippet.Language}");
89+
var declaration =
90+
string.IsNullOrWhiteSpace(snippet.ExpressiveCode)
91+
? snippet.Language
92+
: $"{snippet.Language} {snippet.ExpressiveCode}";
93+
appendLine($"```{declaration}");
9094
appendLine(snippet.Value);
9195
appendLine("```");
9296
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
internal partial class ExpressiveCode
2+
{
3+
#if NET8_0_OR_GREATER
4+
[GeneratedRegex(@"([a-zA-Z0-9\-_]+)(?:\((.*?)\))?")]
5+
protected partial Regex KeyPatternRegex();
6+
#else
7+
protected static readonly Regex KeyPatternRegex = new(@"([a-zA-Z0-9\-_]+)(?:\((.*?)\))?");
8+
#endif
9+
public ExpressiveCode() =>
10+
#if NET8_0_OR_GREATER
11+
Pattern = KeyPatternRegex();
12+
#else
13+
Pattern = KeyPatternRegex;
14+
#endif
15+
public static ExpressiveCode Instance { get; } = new();
16+
public Regex Pattern { get; }
17+
}

src/MarkdownSnippets/Reading/FileSnippetExtractor.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public static async Task AppendUrlAsSnippet(ICollection<Snippet> snippets, strin
4343
throw new SnippetException($"Unable to get UrlAsSnippet: {url}");
4444
}
4545

46-
var snippet = Snippet.Build(1, content!.LineCount(), content!, key, GetLanguageFromPath(url), url);
46+
var snippet = Snippet.Build(1, content!.LineCount(), content!, key, GetLanguageFromPath(url), url, null);
4747
snippets.Add(snippet);
4848

4949
using var reader = new StringReader(content!);
@@ -71,7 +71,7 @@ public static void AppendFileAsSnippet(ICollection<Snippet> snippets, string fil
7171
{
7272
Guard.FileExists(filePath, nameof(filePath));
7373
var text = File.ReadAllText(filePath);
74-
var snippet = Snippet.Build(1, text.LineCount(), text, key, GetLanguageFromPath(filePath), filePath);
74+
var snippet = Snippet.Build(1, text.LineCount(), text, key, GetLanguageFromPath(filePath), filePath, null);
7575
snippets.Add(snippet);
7676
}
7777

@@ -153,9 +153,9 @@ static IEnumerable<Snippet> GetSnippets(TextReader stringReader, string path, in
153153

154154
var trimmedLine = line.Trim();
155155

156-
if (StartEndTester.IsStart(trimmedLine, path, out var key, out var endFunc))
156+
if (StartEndTester.IsStart(trimmedLine, path, out var key, out var endFunc, out var expressive))
157157
{
158-
loopStack.Push(endFunc, key, index, maxWidth, newLine);
158+
loopStack.Push(endFunc, key, index, maxWidth, newLine, expressive);
159159
continue;
160160
}
161161

@@ -207,7 +207,8 @@ static Snippet BuildSnippet(string path, LoopStack loopStack, string language, i
207207
key: loopState.Key,
208208
value: value,
209209
path: path,
210-
language: language.ToLowerInvariant()
210+
language: language.ToLowerInvariant(),
211+
expressiveCode: loopState.ExpressiveCode
211212
);
212213
}
213214
}

src/MarkdownSnippets/Reading/LoopStack.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ public void AppendLine(string line)
1515

1616
public void Pop() => stack.Pop();
1717

18-
public void Push(EndFunc endFunc, string key, int startLine, int maxWidth, string newLine)
18+
public void Push(EndFunc endFunc, string key, int startLine, int maxWidth, string newLine, string? block)
1919
{
20-
var state = new LoopState(key, endFunc, startLine, maxWidth, newLine);
20+
var state = new LoopState(key, endFunc, startLine, maxWidth, newLine, block);
2121
stack.Push(state);
2222
}
2323

src/MarkdownSnippets/Reading/LoopState.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[DebuggerDisplay("Key={Key}")]
2-
class LoopState(string key, EndFunc endFunc, int startLine, int maxWidth, string newLine)
2+
class LoopState(string key, EndFunc endFunc, int startLine, int maxWidth, string newLine, string? expressiveCode = null)
33
{
44
public string GetLines()
55
{
@@ -85,4 +85,5 @@ void CheckWhiteSpace(string line, char whiteSpace)
8585
public EndFunc EndFunc { get; } = endFunc;
8686
public int StartLine { get; } = startLine;
8787
int newlineCount;
88+
public string? ExpressiveCode { get; } = expressiveCode;
8889
}

src/MarkdownSnippets/Reading/Snippet.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public static Snippet BuildError(string key, int lineNumberInError, string path,
2626
/// <summary>
2727
/// Initialise a new instance of <see cref="Snippet"/>.
2828
/// </summary>
29-
public static Snippet Build(int startLine, int endLine, string value, string key, string language, string? path)
29+
public static Snippet Build(int startLine, int endLine, string value, string key, string language, string? path, string? expressiveCode)
3030
{
3131
Guard.AgainstNullAndEmpty(key, nameof(key));
3232
Guard.AgainstEmpty(path, nameof(path));
@@ -46,6 +46,7 @@ public static Snippet Build(int startLine, int endLine, string value, string key
4646
Key = key,
4747
language = language,
4848
Path = path,
49+
ExpressiveCode = expressiveCode,
4950
Error = ""
5051
};
5152
}
@@ -59,6 +60,12 @@ public static Snippet Build(int startLine, int endLine, string value, string key
5960
/// </summary>
6061
public string Key { get; private init; } = null!;
6162

63+
/// <summary>
64+
/// An associated expressive code block with the snippet
65+
/// See https://expressive-code.com/
66+
/// </summary>
67+
public string? ExpressiveCode { get; private init; }
68+
6269
/// <summary>
6370
/// The language of the snippet, extracted from the file extension of the input file.
6471
/// </summary>
@@ -70,6 +77,7 @@ public string Language
7077
return language!;
7178
}
7279
}
80+
7381
string? language;
7482

7583
/// <summary>
@@ -98,6 +106,7 @@ public string? FileLocation
98106
{
99107
return null;
100108
}
109+
101110
return $"{Path}({StartLine}-{EndLine})";
102111
}
103112
}

src/MarkdownSnippets/Reading/StartEndTester.cs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
public delegate bool EndFunc(string line);
22

3-
static class StartEndTester
3+
static partial class StartEndTester
44
{
55
internal static bool IsStartOrEnd(string trimmedLine) =>
66
IsBeginSnippet(trimmedLine) ||
@@ -12,21 +12,28 @@ internal static bool IsStart(
1212
string trimmedLine,
1313
string path,
1414
[NotNullWhen(true)] out string? currentKey,
15-
[NotNullWhen(true)] out EndFunc? endFunc)
15+
[NotNullWhen(true)] out EndFunc? endFunc,
16+
out string? expressiveCode
17+
)
1618
{
17-
if (IsBeginSnippet(trimmedLine, path, out currentKey))
19+
if (IsBeginSnippet(trimmedLine, path, out currentKey, out var block))
1820
{
1921
endFunc = IsEndSnippet;
22+
expressiveCode = block;
2023
return true;
2124
}
2225

2326
if (IsStartRegion(trimmedLine, out currentKey))
2427
{
2528
endFunc = IsEndRegion;
29+
// not supported for regions
30+
expressiveCode = null;
2631
return true;
2732
}
2833

34+
expressiveCode = null;
2935
endFunc = throwFunc;
36+
3037
return false;
3138
}
3239

@@ -78,8 +85,11 @@ static bool IsBeginSnippet(string line)
7885
internal static bool IsBeginSnippet(
7986
string line,
8087
string path,
81-
[NotNullWhen(true)] out string? key)
88+
[NotNullWhen(true)] out string? key,
89+
[NotNullWhen(true)] out string? expressiveCode
90+
)
8291
{
92+
expressiveCode = null;
8393
var beginSnippetIndex = IndexOf(line, "begin-snippet: ");
8494
if (beginSnippetIndex == -1)
8595
{
@@ -90,8 +100,10 @@ internal static bool IsBeginSnippet(
90100
var startIndex = beginSnippetIndex + 15;
91101
var substring = line
92102
.TrimBackCommentChars(startIndex);
93-
var split = substring.SplitBySpace();
94-
if (split.Length == 0)
103+
104+
var match = ExpressiveCode.Instance.Pattern.Match(substring);
105+
106+
if (match.Length == 0)
95107
{
96108
throw new SnippetReadingException(
97109
$"""
@@ -101,7 +113,8 @@ No Key could be derived.
101113
""");
102114
}
103115

104-
key = split[0];
116+
var partOne = match.Groups[1].Value;
117+
var split = partOne.SplitBySpace();
105118
if (split.Length != 1)
106119
{
107120
throw new SnippetReadingException(
@@ -112,6 +125,9 @@ Too many parts.
112125
""");
113126
}
114127

128+
key = split[0];
129+
expressiveCode = match.Groups[2].Value;
130+
115131
if (KeyValidator.IsValidKey(key.AsSpan()))
116132
{
117133
return true;

0 commit comments

Comments
 (0)