Skip to content

Commit 391fd5d

Browse files
Feat: #353 convert raw html to markdown and viceversa (#381)
* feat: #353 convert raw html to markdown and viceversa * chore: refactor code with creator suggestions * fix: Move package to correct category --------- Co-authored-by: Steven Giesel <[email protected]>
1 parent 6701e02 commit 391fd5d

File tree

5 files changed

+182
-117
lines changed

5 files changed

+182
-117
lines changed

Directory.Packages.props

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.0" />
2626
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0" />
2727
<PackageVersion Include="NCronJob" Version="3.3.8" />
28+
<PackageVersion Include="ReverseMarkdown" Version="4.6.0" />
2829
<PackageVersion Include="System.ServiceModel.Syndication" Version="9.0.0" />
2930
</ItemGroup>
3031
<ItemGroup Label="Tests">
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,115 @@
1-
@using LinkDotNet.Blog.Domain
1+
@using LinkDotNet.Blog.Domain
22
@using LinkDotNet.Blog.Infrastructure
33
@using LinkDotNet.Blog.Infrastructure.Persistence
44
@using LinkDotNet.Blog.Web.Features.Services
5+
@using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components
56
@using NCronJob
67
@inject IJSRuntime JSRuntime
78
@inject ICacheInvalidator CacheInvalidator
89
@inject IInstantJobRegistry InstantJobRegistry
910
@inject IRepository<ShortCode> ShortCodeRepository
1011

1112
<div class="container">
12-
<h3 class="fw-bold">@Title</h3>
13-
<EditForm Model="@model" OnValidSubmit="OnValidBlogPostCreatedAsync">
14-
<DataAnnotationsValidator />
15-
<div class="form-floating mb-3">
16-
<input type="text" class="form-control" id="title" placeholder="Title"
17-
@oninput="args => model.Title = args.Value!.ToString()!" value="@model.Title"/>
18-
<label for="title">Title</label>
19-
<ValidationMessage For="() => model.Title"></ValidationMessage>
20-
</div>
21-
<div class="form-floating mb-3">
22-
<MarkdownTextArea Id="short" Class="form-control" Rows="4" Placeholder="Short Description"
23-
@bind-Value="@model.ShortDescription"
24-
PreviewFunction="ReplaceShortCodes"
25-
></MarkdownTextArea>
26-
<ValidationMessage For="() => model.ShortDescription"></ValidationMessage>
27-
</div>
28-
<div class="form-floating mb-3 relative">
29-
<MarkdownTextArea Id="content" Class="form-control" Rows="20" Placeholder="Content"
30-
PreviewFunction="ReplaceShortCodes"
31-
@bind-Value="@model.Content"></MarkdownTextArea>
32-
<ValidationMessage For="() => model.Content"></ValidationMessage>
33-
34-
<div class="btn-group position-absolute bottom-0 end-0 m-5 extra-buttons">
35-
<button class="btn btn-primary btn-outlined btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
36-
More
37-
</button>
38-
<ul class="dropdown-menu">
39-
@if (shortCodes.Count > 0)
40-
{
41-
<li>
42-
<button type="button" @onclick="OpenShortCodeDialog" class="dropdown-item">
43-
<span>Get shortcode</span>
44-
</button>
45-
</li>
46-
}
47-
<li><button type="button" class="dropdown-item" @onclick="FeatureDialog.Open">Experimental Features</button></li>
48-
</ul>
49-
</div>
50-
</div>
51-
<div class="form-floating mb-3">
52-
<InputText type="url" class="form-control" id="preview" placeholder="Preview-Url" @bind-Value="model.PreviewImageUrl"/>
53-
<label for="preview">Preview-Url</label>
54-
<small for="preview" class="form-text text-body-secondary">The primary image which will be used.</small>
55-
<ValidationMessage For="() => model.PreviewImageUrl"></ValidationMessage>
56-
</div>
57-
<div class="form-floating mb-3">
58-
<InputText type="url" class="form-control" id="fallback-preview" placeholder="Fallback Preview-Url" @bind-Value="model.PreviewImageUrlFallback"/>
59-
<label for="fallback-preview">Fallback Preview-Url</label>
60-
<small for="fallback-preview" class="form-text text-body-secondary">Optional: Used as a fallback if the preview image can't be used by the browser.
61-
<br>For example using a jpg or png as fallback for avif which is not supported in Safari or Edge.</small>
62-
<ValidationMessage For="() => model.PreviewImageUrlFallback"></ValidationMessage>
63-
</div>
64-
<div class="form-floating mb-3">
65-
<InputDate Type="InputDateType.DateTimeLocal" class="form-control" id="scheduled"
66-
placeholder="Scheduled Publish Date" @bind-Value="model.ScheduledPublishDate"
67-
@bind-Value:after="@(() => model.IsPublished &= !IsScheduled)"/>
68-
<label for="scheduled">Scheduled Publish Date</label>
69-
<small for="scheduled" class="form-text text-body-secondary">If set the blog post will be published at the given date.
70-
A blog post with a schedule date can't be set to published.</small>
71-
<ValidationMessage For="() => model.ScheduledPublishDate"></ValidationMessage>
72-
</div>
73-
<div class="form-check form-switch mb-3">
74-
<InputCheckbox class="form-check-input" id="published" @bind-Value="model.IsPublished"/>
75-
<label class="form-check-label" for="published">Publish</label><br/>
76-
<small for="published" class="form-text text-body-secondary">If this blog post is only draft or it will be scheduled, uncheck the box.</small>
77-
<ValidationMessage For="() => model.IsPublished"></ValidationMessage>
78-
</div>
79-
<div class="form-floating mb-3">
80-
<InputText type="text" class="form-control" id="tags" placeholder="Tags" @bind-Value="model.Tags"/>
81-
<label for="tags">Tags (Comma separated)</label>
82-
</div>
83-
@if (BlogPost is not null && !IsScheduled)
84-
{
85-
<div class="form-check form-switch mb-3">
86-
<InputCheckbox class="form-check-input" id="updatedate" @bind-Value="model.ShouldUpdateDate" />
87-
<label class="form-check-label" for="updatedate">Update Publish Date</label><br/>
88-
<small for="updatedate" class="form-text text-body-secondary">If set the publish date is set to now,
89-
otherwise its original date.</small>
90-
</div>
91-
}
92-
<div class="mb-3">
93-
<button class="btn btn-primary position-relative" type="submit" disabled="@(!canSubmit)">Submit</button>
94-
<div class="alert alert-info text-muted form-text mt-3 mb-3">
95-
The first page of the blog is cached. Therefore, the blog post is not immediately visible.
96-
Head over to <a href="/settings">settings</a> to invalidate the cache or enable the checkmark.
97-
<br>
98-
The option should be enabled if you want to publish the blog post immediately and it should be visible on the first page.
99-
</div>
100-
<div class="form-check form-switch mb-3">
101-
<InputCheckbox class="form-check-input" id="invalidate-cache" @bind-Value="model.ShouldInvalidateCache"/>
102-
<label class="form-check-label" for="invalidate-cache">Make it visible immediately</label><br/>
103-
</div>
104-
</div>
105-
</EditForm>
13+
<h3 class="fw-bold">@Title</h3>
14+
<EditForm Model="@model" OnValidSubmit="OnValidBlogPostCreatedAsync">
15+
<DataAnnotationsValidator />
16+
<div class="form-floating mb-3">
17+
<input type="text" class="form-control" id="title" placeholder="Title"
18+
@oninput="args => model.Title = args.Value!.ToString()!" value="@model.Title" />
19+
<label for="title">Title</label>
20+
<ValidationMessage For="() => model.Title"></ValidationMessage>
21+
</div>
22+
<div class="form-floating mb-3">
23+
<MarkdownTextArea Id="short" Class="form-control" Rows="4" Placeholder="Short Description"
24+
@bind-Value="@model.ShortDescription"
25+
PreviewFunction="ReplaceShortCodes"></MarkdownTextArea>
26+
<ValidationMessage For="() => model.ShortDescription"></ValidationMessage>
27+
</div>
28+
<div class="form-floating mb-3 relative">
29+
<MarkdownTextArea Id="content" Class="form-control" Rows="20" Placeholder="Content"
30+
PreviewFunction="ReplaceShortCodes"
31+
@bind-Value="@model.Content"></MarkdownTextArea>
32+
<ValidationMessage For="() => model.Content"></ValidationMessage>
33+
34+
<div class="btn-group position-absolute bottom-0 end-0 m-5 extra-buttons">
35+
<button class="btn btn-primary btn-outlined btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
36+
More
37+
</button>
38+
<ul class="dropdown-menu">
39+
@if (shortCodes.Count > 0)
40+
{
41+
<li>
42+
<button type="button" @onclick="OpenShortCodeDialog" class="dropdown-item">
43+
<span>Get shortcode</span>
44+
</button>
45+
</li>
46+
}
47+
<li><button type="button" class="dropdown-item" @onclick="FeatureDialog.Open">Experimental Features</button></li>
48+
<li><button id="convert" type="button" class="dropdown-item" @onclick="ConvertContent">@ConvertLabel <i class="lab"></i></button></li>
49+
</ul>
50+
</div>
51+
</div>
52+
<div class="form-floating mb-3">
53+
<InputText type="url" class="form-control" id="preview" placeholder="Preview-Url" @bind-Value="model.PreviewImageUrl" />
54+
<label for="preview">Preview-Url</label>
55+
<small for="preview" class="form-text text-body-secondary">The primary image which will be used.</small>
56+
<ValidationMessage For="() => model.PreviewImageUrl"></ValidationMessage>
57+
</div>
58+
<div class="form-floating mb-3">
59+
<InputText type="url" class="form-control" id="fallback-preview" placeholder="Fallback Preview-Url" @bind-Value="model.PreviewImageUrlFallback" />
60+
<label for="fallback-preview">Fallback Preview-Url</label>
61+
<small for="fallback-preview" class="form-text text-body-secondary">
62+
Optional: Used as a fallback if the preview image can't be used by the browser.
63+
<br>For example using a jpg or png as fallback for avif which is not supported in Safari or Edge.
64+
</small>
65+
<ValidationMessage For="() => model.PreviewImageUrlFallback"></ValidationMessage>
66+
</div>
67+
<div class="form-floating mb-3">
68+
<InputDate Type="InputDateType.DateTimeLocal" class="form-control" id="scheduled"
69+
placeholder="Scheduled Publish Date" @bind-Value="model.ScheduledPublishDate"
70+
@bind-Value:after="@(() => model.IsPublished &= !IsScheduled)" />
71+
<label for="scheduled">Scheduled Publish Date</label>
72+
<small for="scheduled" class="form-text text-body-secondary">
73+
If set the blog post will be published at the given date.
74+
A blog post with a schedule date can't be set to published.
75+
</small>
76+
<ValidationMessage For="() => model.ScheduledPublishDate"></ValidationMessage>
77+
</div>
78+
<div class="form-check form-switch mb-3">
79+
<InputCheckbox class="form-check-input" id="published" @bind-Value="model.IsPublished" />
80+
<label class="form-check-label" for="published">Publish</label><br />
81+
<small for="published" class="form-text text-body-secondary">If this blog post is only draft or it will be scheduled, uncheck the box.</small>
82+
<ValidationMessage For="() => model.IsPublished"></ValidationMessage>
83+
</div>
84+
<div class="form-floating mb-3">
85+
<InputText type="text" class="form-control" id="tags" placeholder="Tags" @bind-Value="model.Tags" />
86+
<label for="tags">Tags (Comma separated)</label>
87+
</div>
88+
@if (BlogPost is not null && !IsScheduled)
89+
{
90+
<div class="form-check form-switch mb-3">
91+
<InputCheckbox class="form-check-input" id="updatedate" @bind-Value="model.ShouldUpdateDate" />
92+
<label class="form-check-label" for="updatedate">Update Publish Date</label><br />
93+
<small for="updatedate" class="form-text text-body-secondary">
94+
If set the publish date is set to now,
95+
otherwise its original date.
96+
</small>
97+
</div>
98+
}
99+
<div class="mb-3">
100+
<button class="btn btn-primary position-relative" type="submit" disabled="@(!canSubmit)">Submit</button>
101+
<div class="alert alert-info text-muted form-text mt-3 mb-3">
102+
The first page of the blog is cached. Therefore, the blog post is not immediately visible.
103+
Head over to <a href="/settings">settings</a> to invalidate the cache or enable the checkmark.
104+
<br>
105+
The option should be enabled if you want to publish the blog post immediately and it should be visible on the first page.
106+
</div>
107+
<div class="form-check form-switch mb-3">
108+
<InputCheckbox class="form-check-input" id="invalidate-cache" @bind-Value="model.ShouldInvalidateCache" />
109+
<label class="form-check-label" for="invalidate-cache">Make it visible immediately</label><br />
110+
</div>
111+
</div>
112+
</EditForm>
106113
</div>
107114

108115
<FeatureInfoDialog @ref="FeatureDialog"></FeatureInfoDialog>
@@ -127,17 +134,21 @@
127134

128135
private CreateNewModel model = new();
129136

130-
private bool canSubmit = true;
131-
private IPagedList<ShortCode> shortCodes = PagedList<ShortCode>.Empty;
137+
private string? originalContent = null;
138+
private bool IsContentConverted => !string.IsNullOrWhiteSpace(originalContent);
139+
private string ConvertLabel => !IsContentConverted ? "Convert to markdown" : "Restore";
132140

133-
private bool IsScheduled => model.ScheduledPublishDate.HasValue;
141+
private bool canSubmit = true;
142+
private IPagedList<ShortCode> shortCodes = PagedList<ShortCode>.Empty;
134143

135-
protected override async Task OnInitializedAsync()
136-
{
137-
shortCodes = await ShortCodeRepository.GetAllAsync();
138-
}
144+
private bool IsScheduled => model.ScheduledPublishDate.HasValue;
145+
146+
protected override async Task OnInitializedAsync()
147+
{
148+
shortCodes = await ShortCodeRepository.GetAllAsync();
149+
}
139150

140-
protected override void OnParametersSet()
151+
protected override void OnParametersSet()
141152
{
142153
if (BlogPost is null)
143154
{
@@ -149,16 +160,16 @@
149160

150161
private async Task OnValidBlogPostCreatedAsync()
151162
{
152-
canSubmit = false;
163+
canSubmit = false;
153164
await OnBlogPostCreated.InvokeAsync(model.ToBlogPost());
154165
if (model.ShouldInvalidateCache)
155-
{
156-
CacheInvalidator.Cancel();
157-
}
166+
{
167+
CacheInvalidator.Cancel();
168+
}
158169

159170
InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(parameter: true);
160171
ClearModel();
161-
canSubmit = true;
172+
canSubmit = true;
162173
}
163174

164175
private void ClearModel()
@@ -186,17 +197,35 @@
186197

187198
private Task<string> ReplaceShortCodes(string markdown)
188199
{
189-
foreach (var code in shortCodes)
190-
{
191-
markdown = markdown.Replace($"[[{code.Name}]]", code.MarkdownContent);
192-
}
200+
foreach (var code in shortCodes)
201+
{
202+
markdown = markdown.Replace($"[[{code.Name}]]", code.MarkdownContent);
203+
}
193204

194-
return Task.FromResult(MarkdownConverter.ToMarkupString(markdown).Value);
205+
return Task.FromResult(MarkdownConverter.ToMarkupString(markdown).Value);
195206
}
196207

197208
private void OpenShortCodeDialog()
198209
{
199-
ShortCodeDialog.Open();
200-
StateHasChanged();
210+
ShortCodeDialog.Open();
211+
StateHasChanged();
212+
}
213+
214+
/// <summary>
215+
/// Convert content from HTML to Markdown and viceversa
216+
/// </summary>
217+
private void ConvertContent()
218+
{
219+
if (IsContentConverted)
220+
{
221+
model.Content = originalContent!;
222+
originalContent = null;
223+
}
224+
else
225+
{
226+
originalContent = model.Content;
227+
var converter = new ReverseMarkdown.Converter();
228+
model.Content = converter.Convert(model.Content);
229+
}
201230
}
202231
}

src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/FeatureInfoDialog.razor

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<ModalDialog @ref="FeatureDialog" Title="Additional Features">
1+
<ModalDialog @ref="FeatureDialog" Title="Additional Features">
22
<p>Here you will find a comprehensive list over feature you can use additional to classic markdown</p>
33
<p>Features marked with <i class="lab"></i> are experimental and can change heavily, get removed or the usage
44
changes.</p>
@@ -12,6 +12,9 @@
1212
&lt;slide-show-image src="https://picsum.photos/500/200" title="Title 2"&gt;&lt;/slide-show-image&gt;
1313
&lt;slide-show-image src="https://picsum.photos/550/200" title="Title 3"&gt;&lt;/slide-show-image&gt;
1414
&lt;/slide-show&gt;</pre></code>
15+
<hr />
16+
<h3 style="display:inline-block">Convert To Markdown</h3><i class="lab"></i>
17+
<p>By clicking the button in the editor "Convert to Markdown", you will be able to transform HTML content into Markdown content. You will be also able to restore the original content</p>
1518
</ModalDialog>
1619

1720
@code {

src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<PackageReference Include="Markdig" />
1616
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
1717
<PackageReference Include="Microsoft.Extensions.Options" />
18+
<PackageReference Include="ReverseMarkdown" />
1819
<PackageReference Include="System.ServiceModel.Syndication" />
1920
</ItemGroup>
2021

tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPostTests.cs

+32-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Linq;
33
using AngleSharp.Html.Dom;
44
using Blazored.Toast.Services;
@@ -283,4 +283,35 @@ public void GivenBlogPost_WhenCacheInvalidatedOptionIsSet_CacheIsInvalidated()
283283

284284
token.IsCancellationRequested.ShouldBeTrue();
285285
}
286+
287+
[Fact]
288+
public void ShouldTransformHtmlToMarkdown()
289+
{
290+
var cut = Render<CreateNewBlogPost>();
291+
var content = cut.Find("#content");
292+
content.Input("<h3>My Content</h3>");
293+
var btnConvert = cut.Find("#convert");
294+
btnConvert.Click();
295+
content.TextContent.ShouldBeEquivalentTo("### My Content");
296+
btnConvert.TextContent.Trim().ShouldBeEquivalentTo("Restore");
297+
}
298+
299+
[Fact]
300+
public void ShouldRestoreMarkdownToHtml()
301+
{
302+
var cut = Render<CreateNewBlogPost>();
303+
string htmlContent = "<h3>My Content</h3>";
304+
string markdownContent = "### My Content";
305+
var content = cut.Find("#content");
306+
307+
content.Input(htmlContent);
308+
var btnConvert = cut.Find("#convert");
309+
btnConvert.Click();
310+
content.TextContent.ShouldBeEquivalentTo(markdownContent);
311+
btnConvert.TextContent.Trim().ShouldBeEquivalentTo("Restore");
312+
313+
btnConvert.Click();
314+
content.TextContent.ShouldBeEquivalentTo(htmlContent);
315+
btnConvert.TextContent.Trim().ShouldBeEquivalentTo("Convert to markdown");
316+
}
286317
}

0 commit comments

Comments
 (0)