diff --git a/readme.md b/readme.md index ab781038..f8a644de 100644 --- a/readme.md +++ b/readme.md @@ -460,6 +460,37 @@ Console.WriteLine("Hello, World"); ```` +## Language Override + +By default the language of a rendered fenced code block is derived from the source file extension (e.g. a snippet extracted from a `.cs` file renders as `csharp`). The language can be overridden per snippet by adding a `lang=` token as the first item inside the parenthesised metadata: + +```csharp +// begin-snippet: SampleJson(lang=json) +{"hello": "world"} +// end-snippet +``` + +Renders as: + +````markdown +<-- begin-snippet: SampleJson --> +```json +{"hello": "world"} +``` +<-- end-snippet --> +```` + +`lang=` can be combined with Expressive Code metadata — the language token must come first, followed by a space, then the remaining metadata: + +```csharp +// begin-snippet: SampleJson(lang=json title=config.json) +{"hello": "world"} +// end-snippet +``` + +The value must be lowercase alphanumeric. + + ## More Documentation * Developer Information diff --git a/readme.source.md b/readme.source.md index 7ef05ba8..ba1165a4 100644 --- a/readme.source.md +++ b/readme.source.md @@ -369,6 +369,37 @@ Console.WriteLine("Hello, World"); ```` +## Language Override + +By default the language of a rendered fenced code block is derived from the source file extension (e.g. a snippet extracted from a `.cs` file renders as `csharp`). The language can be overridden per snippet by adding a `lang=` token as the first item inside the parenthesised metadata: + +```csharp +// begin-snippet: SampleJson(lang=json) +{"hello": "world"} +// end-snippet +``` + +Renders as: + +````markdown +<-- begin-snippet: SampleJson --> +```json +{"hello": "world"} +``` +<-- end-snippet --> +```` + +`lang=` can be combined with Expressive Code metadata — the language token must come first, followed by a space, then the remaining metadata: + +```csharp +// begin-snippet: SampleJson(lang=json title=config.json) +{"hello": "world"} +// end-snippet +``` + +The value must be lowercase alphanumeric. + + ## More Documentation include: doc-index diff --git a/src/Directory.Build.props b/src/Directory.Build.props index eb41a839..6f68e08e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU1608;NU1109 - 28.0.2 + 28.1.0 preview 1.0.0 Markdown, Snippets, mdsnippets, documentation, MarkdownSnippets diff --git a/src/MarkdownSnippets/Reading/FileSnippetExtractor.cs b/src/MarkdownSnippets/Reading/FileSnippetExtractor.cs index 9007ae8b..bf3a8cc4 100644 --- a/src/MarkdownSnippets/Reading/FileSnippetExtractor.cs +++ b/src/MarkdownSnippets/Reading/FileSnippetExtractor.cs @@ -154,9 +154,9 @@ static IEnumerable GetSnippets(TextReader stringReader, string path, in var trimmedLine = line.AsSpan().Trim(); - if (StartEndTester.IsStart(trimmedLine, path, out var key, out var endFunc, out var expressive)) + if (StartEndTester.IsStart(trimmedLine, path, out var key, out var endFunc, out var expressive, out var languageOverride)) { - loopStack.Push(endFunc, key, index, maxWidth, newLine, expressive); + loopStack.Push(endFunc, key, index, maxWidth, newLine, expressive, languageOverride); continue; } @@ -208,7 +208,7 @@ static Snippet BuildSnippet(string path, LoopStack loopStack, string language, i key: loopState.Key, value: value, path: path, - language: language, + language: loopState.Language ?? language, expressiveCode: loopState.ExpressiveCode ); } diff --git a/src/MarkdownSnippets/Reading/LoopStack.cs b/src/MarkdownSnippets/Reading/LoopStack.cs index eda4e75a..e80770a4 100644 --- a/src/MarkdownSnippets/Reading/LoopStack.cs +++ b/src/MarkdownSnippets/Reading/LoopStack.cs @@ -15,11 +15,12 @@ public void AppendLine(string line) public void Pop() => stack.Pop(); - public void Push(EndFunc endFunc, CharSpan key, int startLine, int maxWidth, string newLine, CharSpan expressiveCode) + public void Push(EndFunc endFunc, CharSpan key, int startLine, int maxWidth, string newLine, CharSpan expressiveCode, CharSpan language) { var expressiveCodeString = expressiveCode.Length == 0 ? null : expressiveCode.ToString(); + var languageString = language.Length == 0 ? null : language.ToString(); - var state = new LoopState(key.ToString(), endFunc, startLine, maxWidth, newLine, expressiveCodeString); + var state = new LoopState(key.ToString(), endFunc, startLine, maxWidth, newLine, expressiveCodeString, languageString); stack.Push(state); } diff --git a/src/MarkdownSnippets/Reading/LoopState.cs b/src/MarkdownSnippets/Reading/LoopState.cs index 0bcbeb2a..a51ff8eb 100644 --- a/src/MarkdownSnippets/Reading/LoopState.cs +++ b/src/MarkdownSnippets/Reading/LoopState.cs @@ -1,5 +1,5 @@ [DebuggerDisplay("Key={Key}")] -class LoopState(string key, EndFunc endFunc, int startLine, int maxWidth, string newLine, string? expressiveCode = null) +class LoopState(string key, EndFunc endFunc, int startLine, int maxWidth, string newLine, string? expressiveCode = null, string? language = null) { public string GetLines() { @@ -88,4 +88,5 @@ void CheckWhiteSpace(CharSpan line, char whiteSpace) public int StartLine { get; } = startLine; int newlineCount; public string? ExpressiveCode { get; } = expressiveCode; + public string? Language { get; } = language; } \ No newline at end of file diff --git a/src/MarkdownSnippets/Reading/StartEndTester.cs b/src/MarkdownSnippets/Reading/StartEndTester.cs index 4245c94a..0d138f72 100644 --- a/src/MarkdownSnippets/Reading/StartEndTester.cs +++ b/src/MarkdownSnippets/Reading/StartEndTester.cs @@ -14,9 +14,10 @@ internal static bool IsStart( CharSpan path, out CharSpan currentKey, [NotNullWhen(true)] out EndFunc? endFunc, - out CharSpan expressiveCode) + out CharSpan expressiveCode, + out CharSpan language) { - if (IsBeginSnippet(trimmedLine, path, out currentKey, out expressiveCode)) + if (IsBeginSnippet(trimmedLine, path, out currentKey, out expressiveCode, out language)) { endFunc = IsEndSnippet; return true; @@ -27,10 +28,12 @@ internal static bool IsStart( endFunc = IsEndRegion; // not supported for regions expressiveCode = null; + language = null; return true; } expressiveCode = null; + language = null; endFunc = throwFunc; return false; @@ -80,9 +83,18 @@ internal static bool IsBeginSnippet( CharSpan line, CharSpan path, out CharSpan key, - out CharSpan expressiveCode) + out CharSpan expressiveCode) => + IsBeginSnippet(line, path, out key, out expressiveCode, out _); + + internal static bool IsBeginSnippet( + CharSpan line, + CharSpan path, + out CharSpan key, + out CharSpan expressiveCode, + out CharSpan language) { expressiveCode = null; + language = null; var beginSnippetIndex = IndexOf(line, "begin-snippet: "); if (beginSnippetIndex == -1) { @@ -115,7 +127,9 @@ internal static bool IsBeginSnippet( """); } - expressiveCode = substring[(startArgs + 1)..^1].Trim(); + var args = substring[(startArgs + 1)..^1].Trim(); + args = ExtractLanguage(args, key, path, line, out language); + expressiveCode = args; } if (key.Length == 0) @@ -142,6 +156,59 @@ Key cannot contain whitespace or start/end with symbols. """); } + static CharSpan ExtractLanguage(CharSpan args, scoped CharSpan key, scoped CharSpan path, scoped CharSpan line, out CharSpan language) + { + language = null; + if (!args.StartsWith("lang=", StringComparison.Ordinal)) + { + return args; + } + + var rest = args[5..]; + var end = rest.IndexOf(' '); + CharSpan value; + CharSpan remainder; + if (end == -1) + { + value = rest; + remainder = null; + } + else + { + value = rest[..end]; + remainder = rest[(end + 1)..].Trim(); + } + + if (value.Length == 0) + { + throw new SnippetReadingException( + $""" + lang= must have a value. + Key: {key} + Path: {path} + Line: {line} + """); + } + + foreach (var c in value) + { + if (c is < 'a' or > 'z' && c is < '0' or > '9') + { + throw new SnippetReadingException( + $""" + lang value must be lowercase alphanumeric. + Key: {key} + Value: {value.ToString()} + Path: {path} + Line: {line} + """); + } + } + + language = value; + return remainder; + } + static int IndexOf(CharSpan line, CharSpan value) { if (value.Length > line.Length) diff --git a/src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverride.verified.txt b/src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverride.verified.txt new file mode 100644 index 00000000..ed739b1a --- /dev/null +++ b/src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverride.verified.txt @@ -0,0 +1,10 @@ +[ + { + Key: CodeKey, + Language: json, + Value: {"a": 1}, + Error: , + FileLocation: path.cs(1-3), + IsInError: false + } +] \ No newline at end of file diff --git a/src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverrideWithExpressiveCode.verified.txt b/src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverrideWithExpressiveCode.verified.txt new file mode 100644 index 00000000..ed739b1a --- /dev/null +++ b/src/Tests/SnippetExtractor/SnippetExtractorTests.LanguageOverrideWithExpressiveCode.verified.txt @@ -0,0 +1,10 @@ +[ + { + Key: CodeKey, + Language: json, + Value: {"a": 1}, + Error: , + FileLocation: path.cs(1-3), + IsInError: false + } +] \ No newline at end of file diff --git a/src/Tests/SnippetExtractor/SnippetExtractorTests.cs b/src/Tests/SnippetExtractor/SnippetExtractorTests.cs index 0e21e21d..0bfc468a 100644 --- a/src/Tests/SnippetExtractor/SnippetExtractorTests.cs +++ b/src/Tests/SnippetExtractor/SnippetExtractorTests.cs @@ -204,6 +204,30 @@ public Task CanExtractFromXml() return Verify(snippets); } + [Fact] + public Task LanguageOverride() + { + var input = """ + + {"a": 1} + + """; + var snippets = FromText(input); + return Verify(snippets); + } + + [Fact] + public Task LanguageOverrideWithExpressiveCode() + { + var input = """ + + {"a": 1} + + """; + var snippets = FromText(input); + return Verify(snippets); + } + static List FromText(string contents) { using var reader = new StringReader(contents); diff --git a/src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForEmptyLanguageValue.verified.txt b/src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForEmptyLanguageValue.verified.txt new file mode 100644 index 00000000..307d7dc9 --- /dev/null +++ b/src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForEmptyLanguageValue.verified.txt @@ -0,0 +1,8 @@ +{ + Type: SnippetReadingException, + Message: +lang= must have a value. +Key: CodeKey +Path: file +Line: +} \ No newline at end of file diff --git a/src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForInvalidLanguageValue.verified.txt b/src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForInvalidLanguageValue.verified.txt new file mode 100644 index 00000000..9b03dbda --- /dev/null +++ b/src/Tests/StartEndTester_IsBeginSnippetTests.ShouldThrowForInvalidLanguageValue.verified.txt @@ -0,0 +1,9 @@ +{ + Type: SnippetReadingException, + Message: +lang value must be lowercase alphanumeric. +Key: CodeKey +Value: C# +Path: file +Line: +} \ No newline at end of file diff --git a/src/Tests/StartEndTester_IsBeginSnippetTests.cs b/src/Tests/StartEndTester_IsBeginSnippetTests.cs index 78d50e2f..6513e07f 100644 --- a/src/Tests/StartEndTester_IsBeginSnippetTests.cs +++ b/src/Tests/StartEndTester_IsBeginSnippetTests.cs @@ -116,4 +116,34 @@ public void CanExtractWithExpressiveCodeWithCsharpCommentTrailingWhitespace() Assert.Equal("CodeKey", key); Assert.Equal("""title="Program.cs" {1-3}""", expressive); } + + [Fact] + public void CanExtractLanguageOverride() + { + var isBeginSnippet = StartEndTester.IsBeginSnippet("", "file", out var key, out var expressive, out var language); + Assert.True(isBeginSnippet); + Assert.Equal("CodeKey", key); + Assert.Equal("json", language.ToString()); + Assert.Equal(0, expressive.Length); + } + + [Fact] + public void CanExtractLanguageOverrideWithExpressiveCode() + { + var isBeginSnippet = StartEndTester.IsBeginSnippet("""""", "file", out var key, out var expressive, out var language); + Assert.True(isBeginSnippet); + Assert.Equal("CodeKey", key); + Assert.Equal("json", language.ToString()); + Assert.Equal("""title="a.json" """.TrimEnd(), expressive.ToString()); + } + + [Fact] + public Task ShouldThrowForInvalidLanguageValue() => + Throws(() => + StartEndTester.IsBeginSnippet("", "file", out _, out _, out _)); + + [Fact] + public Task ShouldThrowForEmptyLanguageValue() => + Throws(() => + StartEndTester.IsBeginSnippet("", "file", out _, out _, out _)); } \ No newline at end of file