Skip to content

Commit f8ab04a

Browse files
committed
feat: Implement a MarkdownSection component with syntax-highlighted code blocks, copy-to-clipboard functionality, and a static asset service.
1 parent 6308e58 commit f8ab04a

13 files changed

Lines changed: 483 additions & 0 deletions

src/BlazorApps/BlazorApps.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<ItemGroup>
1111
<PackageReference Include="BlazorWasmPreRendering.Build" Version="7.0.0" />
12+
<PackageReference Include="Markdig" Version="0.44.0" />
1213
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
1314
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.0" PrivateAssets="all" />
1415
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.13.2" />
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@using Markdig
2+
@inherits FluentComponentBase
3+
4+
<div>
5+
@HtmlContent
6+
</div>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// ------------------------------------------------------------------------
2+
// This file is licensed to you under the MIT License.
3+
// ------------------------------------------------------------------------
4+
5+
using BlazorApps.Services;
6+
using Markdig;
7+
using Microsoft.AspNetCore.Components;
8+
using Microsoft.FluentUI.AspNetCore.Components;
9+
using Microsoft.JSInterop;
10+
11+
namespace BlazorApps.Components;
12+
13+
public partial class MarkdownSection : FluentComponentBase
14+
{
15+
private IJSObjectReference _jsModule = default!;
16+
private bool _markdownChanged = false;
17+
private string? _content;
18+
private string? _fromAsset;
19+
20+
[Inject]
21+
protected IJSRuntime JSRuntime { get; set; } = default!;
22+
23+
[Inject]
24+
private IStaticAssetService StaticAssetService { get; set; } = default!;
25+
26+
/// <summary>
27+
/// Gets or sets the Markdown content
28+
/// </summary>
29+
[Parameter]
30+
public string? Content
31+
{
32+
get => _content;
33+
set
34+
{
35+
if (_content is not null && !_content.Equals(value))
36+
{
37+
_markdownChanged = true;
38+
}
39+
_content = value;
40+
}
41+
}
42+
43+
/// <summary>
44+
/// Gets or sets asset to read the Markdown from
45+
/// </summary>
46+
[Parameter]
47+
public string? FromAsset
48+
{
49+
get => _fromAsset;
50+
set
51+
{
52+
if (_fromAsset is not null && !_fromAsset.Equals(value))
53+
{
54+
_markdownChanged = true;
55+
}
56+
_fromAsset = value;
57+
}
58+
}
59+
60+
[Parameter]
61+
public EventCallback OnContentConverted { get; set; }
62+
63+
public MarkupString HtmlContent { get; private set; }
64+
65+
protected override void OnInitialized()
66+
{
67+
if (Content is null && string.IsNullOrEmpty(FromAsset))
68+
{
69+
throw new ArgumentException("You need to provide either Content or FromAsset parameter");
70+
}
71+
}
72+
73+
protected override async Task OnAfterRenderAsync(bool firstRender)
74+
{
75+
if (firstRender)
76+
{
77+
// import code for highlighting code blocks
78+
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import",
79+
"./Components/MarkdownSection.razor.js");
80+
}
81+
82+
if (firstRender || _markdownChanged)
83+
{
84+
_markdownChanged = false;
85+
86+
// create markup from markdown source
87+
HtmlContent = await MarkdownToMarkupStringAsync();
88+
StateHasChanged();
89+
90+
// notify that content converted from markdown
91+
if (OnContentConverted.HasDelegate)
92+
{
93+
await OnContentConverted.InvokeAsync();
94+
}
95+
await _jsModule.InvokeVoidAsync("highlight");
96+
await _jsModule.InvokeVoidAsync("addCopyButton");
97+
}
98+
}
99+
100+
/// <summary>
101+
/// Converts markdown, provided in Content or from markdown file stored as a static asset, to MarkupString for rendering.
102+
/// </summary>
103+
/// <returns>MarkupString</returns>
104+
private async Task<MarkupString> MarkdownToMarkupStringAsync()
105+
{
106+
string? markdown;
107+
if (string.IsNullOrEmpty(FromAsset))
108+
{
109+
markdown = Content;
110+
}
111+
else
112+
{
113+
markdown = await StaticAssetService.GetAsync(FromAsset);
114+
}
115+
116+
return ConvertToMarkupString(markdown);
117+
}
118+
private static MarkupString ConvertToMarkupString(string? value)
119+
{
120+
if (!string.IsNullOrWhiteSpace(value))
121+
{
122+
var builder = new MarkdownPipelineBuilder()
123+
.UseAdvancedExtensions()
124+
.Use<MarkdownSectionPreCodeExtension>();
125+
126+
var pipeline = builder.Build();
127+
128+
// Convert markdown string to HTML
129+
var html = Markdown.ToHtml(value, pipeline);
130+
131+
// Return sanitized HTML as a MarkupString that Blazor can render
132+
return new MarkupString(html);
133+
}
134+
135+
return new MarkupString();
136+
}
137+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
::deep th {
2+
padding: 0 5px 0 5px;
3+
border-width: 1px;
4+
}
5+
6+
::deep td {
7+
padding: 0 5px 0 5px;
8+
border-width: 1px;
9+
}
10+
11+
::deep .snippet {
12+
margin-bottom: 0.5rem;
13+
border: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-rest);
14+
border-radius: calc(var(--control-corner-radius) * 1px);
15+
overflow-y: auto;
16+
}
17+
18+
::deep .hljs {
19+
background-color: var(--neutral-layer-2);
20+
tab-size: 2em;
21+
}
22+
23+
::deep .hljs-copy-button {
24+
border-color: var(--neutral-fill-strong-rest);
25+
color: var(--accent-fill-rest);
26+
top: 0.4rem;
27+
/*background-color: var(--neutral-layer-2) !important;*/
28+
/*background-image: url('data:image/svg+xml;utf-8,<svg style="fill: none; width: 16px; height: 16px;" width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6 5C5.73478 5 5.48043 5.10536 5.29289 5.29289C5.10536 5.48043 5 5.73478 5 6V20C5 20.2652 5.10536 20.5196 5.29289 20.7071C5.48043 20.8946 5.73478 21 6 21H18C18.2652 21 18.5196 20.8946 18.7071 20.7071C18.8946 20.5196 19 20.2652 19 20V6C19 5.73478 18.8946 5.48043 18.7071 5.29289C18.5196 5.10536 18.2652 5 18 5H16C15.4477 5 15 4.55228 15 4C15 3.44772 15.4477 3 16 3H18C18.7956 3 19.5587 3.31607 20.1213 3.87868C20.6839 4.44129 21 5.20435 21 6V20C21 20.7957 20.6839 21.5587 20.1213 22.1213C19.5587 22.6839 18.7957 23 18 23H6C5.20435 23 4.44129 22.6839 3.87868 22.1213C3.31607 21.5587 3 20.7957 3 20V6C3 5.20435 3.31607 4.44129 3.87868 3.87868C4.44129 3.31607 5.20435 3 6 3H8C8.55228 3 9 3.44772 9 4C9 4.55228 8.55228 5 8 5H6Z" fill="black"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 3C7 1.89543 7.89543 1 9 1H15C16.1046 1 17 1.89543 17 3V5C17 6.10457 16.1046 7 15 7H9C7.89543 7 7 6.10457 7 5V3ZM15 3H9V5H15V3Z" fill="black" /></svg>');*/
29+
}
30+
31+
::deep .hljs-copy-button:hover {
32+
border-color: var(--neutral-fill-strong-hover);
33+
}
34+
35+
::deep .hljs-copy-button:active {
36+
border-color: var(--neutral-fill-strong-active);
37+
}
38+
39+
::deep .hljs-copy {
40+
cursor: pointer;
41+
border-color: var(--neutral-stroke-rest);
42+
border-radius: calc(var(--control-corner-radius) * 1px);
43+
color: var(--accent-fill-rest);
44+
background-color: var(--neutral-layer-2);
45+
top: 0.4rem;
46+
}
47+
48+
::deep .hljs-copy {
49+
position: absolute;
50+
transform: translateX(calc(100% + 1.125em));
51+
right: 1em;
52+
transition: background-color 200ms ease, transform 200ms ease-out;
53+
}
54+
55+
::deep .hljs-copy-wrapper {
56+
position: relative;
57+
overflow: hidden
58+
}
59+
60+
::deep .hljs-copy-wrapper:hover .hljs-copy, .hljs-copy:focus {
61+
transform: translateX(0);
62+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export function highlight() {
2+
var preTagList = document.getElementsByTagName('pre');
3+
var numberOfPreTags = preTagList.length;
4+
for (var i = 0; i < numberOfPreTags; i++) {
5+
var codeTag = preTagList[i].getElementsByTagName('code');
6+
if (hljs) {
7+
hljs.highlightElement(codeTag[0]);
8+
}
9+
}
10+
}
11+
12+
export function addCopyButton() {
13+
var snippets = document.querySelectorAll('.snippet');
14+
var numberOfSnippets = snippets.length;
15+
for (var i = 0; i < numberOfSnippets; i++) {
16+
let copyButton = snippets[i].getElementsByClassName("hljs-copy")
17+
if (copyButton.length === 0) {
18+
let code = snippets[i].getElementsByTagName('code')[0].innerText;
19+
snippets[i].innerHTML = snippets[i].innerHTML + '<button class="hljs-copy">Copy</button>'; // append copy button
20+
21+
copyButton[0].addEventListener("click", function () {
22+
navigator.clipboard.writeText(code);
23+
24+
this.innerText = 'Copied!';
25+
let button = this;
26+
setTimeout(function () {
27+
button.innerText = 'Copy';
28+
}, 1000)
29+
});
30+
}
31+
}
32+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// ------------------------------------------------------------------------
2+
// This file is licensed to you under the MIT License.
3+
// ------------------------------------------------------------------------
4+
5+
using Markdig.Renderers;
6+
using Markdig;
7+
using Markdig.Renderers.Html;
8+
9+
namespace BlazorApps.Components;
10+
11+
internal class MarkdownSectionPreCodeExtension : IMarkdownExtension
12+
{
13+
public void Setup(MarkdownPipelineBuilder pipeline)
14+
{
15+
}
16+
17+
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
18+
{
19+
var htmlRenderer = renderer as TextRendererBase<HtmlRenderer>;
20+
if (htmlRenderer == null)
21+
{
22+
return;
23+
}
24+
25+
var originalCodeBlockRenderer = htmlRenderer.ObjectRenderers.FindExact<CodeBlockRenderer>();
26+
if (originalCodeBlockRenderer != null)
27+
{
28+
htmlRenderer.ObjectRenderers.Remove(originalCodeBlockRenderer);
29+
}
30+
31+
htmlRenderer.ObjectRenderers.AddIfNotAlready(new MarkdownSectionPreCodeRenderer(
32+
new MarkdownSectionPreCodeRendererOptions
33+
{
34+
PreTagAttributes = "{.snippet .hljs-copy-wrapper}",
35+
})
36+
);
37+
}
38+
}

0 commit comments

Comments
 (0)