Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: #353 convert raw html to markdown and viceversa #381

Merged
merged 5 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<ItemGroup Label="Code Analyzers">
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
<PackageVersion Include="ReverseMarkdown" Version="4.6.0" />
linkdotnet marked this conversation as resolved.
Show resolved Hide resolved
</ItemGroup>
<ItemGroup Label="Infrastructure">
<PackageVersion Include="Azure.Storage.Blobs" Version="12.23.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,108 +1,120 @@
@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>
</ul>
</div>
<div class="btn-group position-absolute bottom-0 m-5 extra-buttons">
FrancescoRepo marked this conversation as resolved.
Show resolved Hide resolved
<button id="btnConvert" class="btn btn-primary btn-outlined btn-sm" type="button" @onclick="convertContent">
FrancescoRepo marked this conversation as resolved.
Show resolved Hide resolved
<i class="lab"></i>
@BtnConvertLabel
</button>
</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 +139,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 BtnConvertLabel => !IsContentConverted ? "Convert to markdown" : "Restore";
FrancescoRepo marked this conversation as resolved.
Show resolved Hide resolved

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 +165,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 +202,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()
FrancescoRepo marked this conversation as resolved.
Show resolved Hide resolved
{
if (IsContentConverted)
{
model.Content = originalContent!;
originalContent = null;
}
else
{
originalContent = model.Content; //keep the original inserted content
FrancescoRepo marked this conversation as resolved.
Show resolved Hide resolved
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("#btnConvert");
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("#btnConvert");
btnConvert.Click();
content.TextContent.ShouldBeEquivalentTo(markdownContent);
btnConvert.TextContent.Trim().ShouldBeEquivalentTo("Restore");

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