Skip to content

Commit

Permalink
Feat: #353 convert raw html to markdown and viceversa (#381)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
FrancescoRepo and linkdotnet authored Nov 27, 2024
1 parent 6701e02 commit 391fd5d
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 117 deletions.
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageVersion Include="NCronJob" Version="3.3.8" />
<PackageVersion Include="ReverseMarkdown" Version="4.6.0" />
<PackageVersion Include="System.ServiceModel.Syndication" Version="9.0.0" />
</ItemGroup>
<ItemGroup Label="Tests">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,108 +1,115 @@
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Infrastructure
@using LinkDotNet.Blog.Infrastructure.Persistence
@using LinkDotNet.Blog.Web.Features.Services
@using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components
@using NCronJob
@inject IJSRuntime JSRuntime
@inject ICacheInvalidator CacheInvalidator
@inject IInstantJobRegistry InstantJobRegistry
@inject IRepository<ShortCode> ShortCodeRepository

<div class="container">
<h3 class="fw-bold">@Title</h3>
<EditForm Model="@model" OnValidSubmit="OnValidBlogPostCreatedAsync">
<DataAnnotationsValidator />
<div class="form-floating mb-3">
<input type="text" class="form-control" id="title" placeholder="Title"
@oninput="args => model.Title = args.Value!.ToString()!" value="@model.Title"/>
<label for="title">Title</label>
<ValidationMessage For="() => model.Title"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<MarkdownTextArea Id="short" Class="form-control" Rows="4" Placeholder="Short Description"
@bind-Value="@model.ShortDescription"
PreviewFunction="ReplaceShortCodes"
></MarkdownTextArea>
<ValidationMessage For="() => model.ShortDescription"></ValidationMessage>
</div>
<div class="form-floating mb-3 relative">
<MarkdownTextArea Id="content" Class="form-control" Rows="20" Placeholder="Content"
PreviewFunction="ReplaceShortCodes"
@bind-Value="@model.Content"></MarkdownTextArea>
<ValidationMessage For="() => model.Content"></ValidationMessage>

<div class="btn-group position-absolute bottom-0 end-0 m-5 extra-buttons">
<button class="btn btn-primary btn-outlined btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
More
</button>
<ul class="dropdown-menu">
@if (shortCodes.Count > 0)
{
<li>
<button type="button" @onclick="OpenShortCodeDialog" class="dropdown-item">
<span>Get shortcode</span>
</button>
</li>
}
<li><button type="button" class="dropdown-item" @onclick="FeatureDialog.Open">Experimental Features</button></li>
</ul>
</div>
</div>
<div class="form-floating mb-3">
<InputText type="url" class="form-control" id="preview" placeholder="Preview-Url" @bind-Value="model.PreviewImageUrl"/>
<label for="preview">Preview-Url</label>
<small for="preview" class="form-text text-body-secondary">The primary image which will be used.</small>
<ValidationMessage For="() => model.PreviewImageUrl"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<InputText type="url" class="form-control" id="fallback-preview" placeholder="Fallback Preview-Url" @bind-Value="model.PreviewImageUrlFallback"/>
<label for="fallback-preview">Fallback Preview-Url</label>
<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.
<br>For example using a jpg or png as fallback for avif which is not supported in Safari or Edge.</small>
<ValidationMessage For="() => model.PreviewImageUrlFallback"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<InputDate Type="InputDateType.DateTimeLocal" class="form-control" id="scheduled"
placeholder="Scheduled Publish Date" @bind-Value="model.ScheduledPublishDate"
@bind-Value:after="@(() => model.IsPublished &= !IsScheduled)"/>
<label for="scheduled">Scheduled Publish Date</label>
<small for="scheduled" class="form-text text-body-secondary">If set the blog post will be published at the given date.
A blog post with a schedule date can't be set to published.</small>
<ValidationMessage For="() => model.ScheduledPublishDate"></ValidationMessage>
</div>
<div class="form-check form-switch mb-3">
<InputCheckbox class="form-check-input" id="published" @bind-Value="model.IsPublished"/>
<label class="form-check-label" for="published">Publish</label><br/>
<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>
<ValidationMessage For="() => model.IsPublished"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<InputText type="text" class="form-control" id="tags" placeholder="Tags" @bind-Value="model.Tags"/>
<label for="tags">Tags (Comma separated)</label>
</div>
@if (BlogPost is not null && !IsScheduled)
{
<div class="form-check form-switch mb-3">
<InputCheckbox class="form-check-input" id="updatedate" @bind-Value="model.ShouldUpdateDate" />
<label class="form-check-label" for="updatedate">Update Publish Date</label><br/>
<small for="updatedate" class="form-text text-body-secondary">If set the publish date is set to now,
otherwise its original date.</small>
</div>
}
<div class="mb-3">
<button class="btn btn-primary position-relative" type="submit" disabled="@(!canSubmit)">Submit</button>
<div class="alert alert-info text-muted form-text mt-3 mb-3">
The first page of the blog is cached. Therefore, the blog post is not immediately visible.
Head over to <a href="/settings">settings</a> to invalidate the cache or enable the checkmark.
<br>
The option should be enabled if you want to publish the blog post immediately and it should be visible on the first page.
</div>
<div class="form-check form-switch mb-3">
<InputCheckbox class="form-check-input" id="invalidate-cache" @bind-Value="model.ShouldInvalidateCache"/>
<label class="form-check-label" for="invalidate-cache">Make it visible immediately</label><br/>
</div>
</div>
</EditForm>
<h3 class="fw-bold">@Title</h3>
<EditForm Model="@model" OnValidSubmit="OnValidBlogPostCreatedAsync">
<DataAnnotationsValidator />
<div class="form-floating mb-3">
<input type="text" class="form-control" id="title" placeholder="Title"
@oninput="args => model.Title = args.Value!.ToString()!" value="@model.Title" />
<label for="title">Title</label>
<ValidationMessage For="() => model.Title"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<MarkdownTextArea Id="short" Class="form-control" Rows="4" Placeholder="Short Description"
@bind-Value="@model.ShortDescription"
PreviewFunction="ReplaceShortCodes"></MarkdownTextArea>
<ValidationMessage For="() => model.ShortDescription"></ValidationMessage>
</div>
<div class="form-floating mb-3 relative">
<MarkdownTextArea Id="content" Class="form-control" Rows="20" Placeholder="Content"
PreviewFunction="ReplaceShortCodes"
@bind-Value="@model.Content"></MarkdownTextArea>
<ValidationMessage For="() => model.Content"></ValidationMessage>

<div class="btn-group position-absolute bottom-0 end-0 m-5 extra-buttons">
<button class="btn btn-primary btn-outlined btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
More
</button>
<ul class="dropdown-menu">
@if (shortCodes.Count > 0)
{
<li>
<button type="button" @onclick="OpenShortCodeDialog" class="dropdown-item">
<span>Get shortcode</span>
</button>
</li>
}
<li><button type="button" class="dropdown-item" @onclick="FeatureDialog.Open">Experimental Features</button></li>
<li><button id="convert" type="button" class="dropdown-item" @onclick="ConvertContent">@ConvertLabel <i class="lab"></i></button></li>
</ul>
</div>
</div>
<div class="form-floating mb-3">
<InputText type="url" class="form-control" id="preview" placeholder="Preview-Url" @bind-Value="model.PreviewImageUrl" />
<label for="preview">Preview-Url</label>
<small for="preview" class="form-text text-body-secondary">The primary image which will be used.</small>
<ValidationMessage For="() => model.PreviewImageUrl"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<InputText type="url" class="form-control" id="fallback-preview" placeholder="Fallback Preview-Url" @bind-Value="model.PreviewImageUrlFallback" />
<label for="fallback-preview">Fallback Preview-Url</label>
<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.
<br>For example using a jpg or png as fallback for avif which is not supported in Safari or Edge.
</small>
<ValidationMessage For="() => model.PreviewImageUrlFallback"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<InputDate Type="InputDateType.DateTimeLocal" class="form-control" id="scheduled"
placeholder="Scheduled Publish Date" @bind-Value="model.ScheduledPublishDate"
@bind-Value:after="@(() => model.IsPublished &= !IsScheduled)" />
<label for="scheduled">Scheduled Publish Date</label>
<small for="scheduled" class="form-text text-body-secondary">
If set the blog post will be published at the given date.
A blog post with a schedule date can't be set to published.
</small>
<ValidationMessage For="() => model.ScheduledPublishDate"></ValidationMessage>
</div>
<div class="form-check form-switch mb-3">
<InputCheckbox class="form-check-input" id="published" @bind-Value="model.IsPublished" />
<label class="form-check-label" for="published">Publish</label><br />
<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>
<ValidationMessage For="() => model.IsPublished"></ValidationMessage>
</div>
<div class="form-floating mb-3">
<InputText type="text" class="form-control" id="tags" placeholder="Tags" @bind-Value="model.Tags" />
<label for="tags">Tags (Comma separated)</label>
</div>
@if (BlogPost is not null && !IsScheduled)
{
<div class="form-check form-switch mb-3">
<InputCheckbox class="form-check-input" id="updatedate" @bind-Value="model.ShouldUpdateDate" />
<label class="form-check-label" for="updatedate">Update Publish Date</label><br />
<small for="updatedate" class="form-text text-body-secondary">
If set the publish date is set to now,
otherwise its original date.
</small>
</div>
}
<div class="mb-3">
<button class="btn btn-primary position-relative" type="submit" disabled="@(!canSubmit)">Submit</button>
<div class="alert alert-info text-muted form-text mt-3 mb-3">
The first page of the blog is cached. Therefore, the blog post is not immediately visible.
Head over to <a href="/settings">settings</a> to invalidate the cache or enable the checkmark.
<br>
The option should be enabled if you want to publish the blog post immediately and it should be visible on the first page.
</div>
<div class="form-check form-switch mb-3">
<InputCheckbox class="form-check-input" id="invalidate-cache" @bind-Value="model.ShouldInvalidateCache" />
<label class="form-check-label" for="invalidate-cache">Make it visible immediately</label><br />
</div>
</div>
</EditForm>
</div>

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

private CreateNewModel model = new();

private bool canSubmit = true;
private IPagedList<ShortCode> shortCodes = PagedList<ShortCode>.Empty;
private string? originalContent = null;
private bool IsContentConverted => !string.IsNullOrWhiteSpace(originalContent);
private string ConvertLabel => !IsContentConverted ? "Convert to markdown" : "Restore";

private bool IsScheduled => model.ScheduledPublishDate.HasValue;
private bool canSubmit = true;
private IPagedList<ShortCode> shortCodes = PagedList<ShortCode>.Empty;

protected override async Task OnInitializedAsync()
{
shortCodes = await ShortCodeRepository.GetAllAsync();
}
private bool IsScheduled => model.ScheduledPublishDate.HasValue;

protected override async Task OnInitializedAsync()
{
shortCodes = await ShortCodeRepository.GetAllAsync();
}

protected override void OnParametersSet()
protected override void OnParametersSet()
{
if (BlogPost is null)
{
Expand All @@ -149,16 +160,16 @@

private async Task OnValidBlogPostCreatedAsync()
{
canSubmit = false;
canSubmit = false;
await OnBlogPostCreated.InvokeAsync(model.ToBlogPost());
if (model.ShouldInvalidateCache)
{
CacheInvalidator.Cancel();
}
{
CacheInvalidator.Cancel();
}

InstantJobRegistry.RunInstantJob<SimilarBlogPostJob>(parameter: true);
ClearModel();
canSubmit = true;
canSubmit = true;
}

private void ClearModel()
Expand Down Expand Up @@ -186,17 +197,35 @@

private Task<string> ReplaceShortCodes(string markdown)
{
foreach (var code in shortCodes)
{
markdown = markdown.Replace($"[[{code.Name}]]", code.MarkdownContent);
}
foreach (var code in shortCodes)
{
markdown = markdown.Replace($"[[{code.Name}]]", code.MarkdownContent);
}

return Task.FromResult(MarkdownConverter.ToMarkupString(markdown).Value);
return Task.FromResult(MarkdownConverter.ToMarkupString(markdown).Value);
}

private void OpenShortCodeDialog()
{
ShortCodeDialog.Open();
StateHasChanged();
ShortCodeDialog.Open();
StateHasChanged();
}

/// <summary>
/// Convert content from HTML to Markdown and viceversa
/// </summary>
private void ConvertContent()
{
if (IsContentConverted)
{
model.Content = originalContent!;
originalContent = null;
}
else
{
originalContent = model.Content;
var converter = new ReverseMarkdown.Converter();
model.Content = converter.Convert(model.Content);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<ModalDialog @ref="FeatureDialog" Title="Additional Features">
<ModalDialog @ref="FeatureDialog" Title="Additional Features">
<p>Here you will find a comprehensive list over feature you can use additional to classic markdown</p>
<p>Features marked with <i class="lab"></i> are experimental and can change heavily, get removed or the usage
changes.</p>
Expand All @@ -12,6 +12,9 @@
&lt;slide-show-image src="https://picsum.photos/500/200" title="Title 2"&gt;&lt;/slide-show-image&gt;
&lt;slide-show-image src="https://picsum.photos/550/200" title="Title 3"&gt;&lt;/slide-show-image&gt;
&lt;/slide-show&gt;</pre></code>
<hr />
<h3 style="display:inline-block">Convert To Markdown</h3><i class="lab"></i>
<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>
</ModalDialog>

@code {
Expand Down
1 change: 1 addition & 0 deletions src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="Markdig" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ReverseMarkdown" />
<PackageReference Include="System.ServiceModel.Syndication" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using AngleSharp.Html.Dom;
using Blazored.Toast.Services;
Expand Down Expand Up @@ -283,4 +283,35 @@ public void GivenBlogPost_WhenCacheInvalidatedOptionIsSet_CacheIsInvalidated()

token.IsCancellationRequested.ShouldBeTrue();
}

[Fact]
public void ShouldTransformHtmlToMarkdown()
{
var cut = Render<CreateNewBlogPost>();
var content = cut.Find("#content");
content.Input("<h3>My Content</h3>");
var btnConvert = cut.Find("#convert");
btnConvert.Click();
content.TextContent.ShouldBeEquivalentTo("### My Content");
btnConvert.TextContent.Trim().ShouldBeEquivalentTo("Restore");
}

[Fact]
public void ShouldRestoreMarkdownToHtml()
{
var cut = Render<CreateNewBlogPost>();
string htmlContent = "<h3>My Content</h3>";
string markdownContent = "### My Content";
var content = cut.Find("#content");

content.Input(htmlContent);
var btnConvert = cut.Find("#convert");
btnConvert.Click();
content.TextContent.ShouldBeEquivalentTo(markdownContent);
btnConvert.TextContent.Trim().ShouldBeEquivalentTo("Restore");

btnConvert.Click();
content.TextContent.ShouldBeEquivalentTo(htmlContent);
btnConvert.TextContent.Trim().ShouldBeEquivalentTo("Convert to markdown");
}
}

0 comments on commit 391fd5d

Please sign in to comment.