Compare commits
7 Commits
razorActio
...
fsharp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a0004bc5 | ||
|
|
b7486dcc85 | ||
|
|
ab9c9fdc90 | ||
|
|
60fe3d8c2a | ||
|
|
115580f0f4 | ||
|
|
c9229018db | ||
|
|
03eeef20d7 |
34
TerribleDev.Blog.MarkdownPlugins/Extensions.cs
Normal file
34
TerribleDev.Blog.MarkdownPlugins/Extensions.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace TerribleDev.Blog.MarkdownPlugins
|
||||
{
|
||||
public static class StringExtension
|
||||
{
|
||||
public static string WithoutSpecialCharacters(this string str)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
foreach (char c in str)
|
||||
{
|
||||
if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == '-')
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
public static string RemoveSpecialCharacters(string str)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
foreach (char c in str)
|
||||
{
|
||||
if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == '-')
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
62
TerribleDev.Blog.MarkdownPlugins/ExternalLinkParser.cs
Normal file
62
TerribleDev.Blog.MarkdownPlugins/ExternalLinkParser.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
namespace TerribleDev.Blog.MarkdownPlugins
|
||||
{
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Markdig;
|
||||
using Markdig.Renderers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Renderers.Html.Inlines;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
/// <summary>
|
||||
/// Extension for extending image Markdown links in case a video or an audio file is linked and output proper link.
|
||||
/// </summary>
|
||||
/// <seealso cref="Markdig.IMarkdownExtension" />
|
||||
public class TargetLinkExtension : IMarkdownExtension
|
||||
{
|
||||
|
||||
public void Setup(MarkdownPipelineBuilder pipeline)
|
||||
{
|
||||
}
|
||||
|
||||
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
|
||||
{
|
||||
var htmlRenderer = renderer as HtmlRenderer;
|
||||
if (htmlRenderer != null)
|
||||
{
|
||||
var inlineRenderer = htmlRenderer.ObjectRenderers.FindExact<LinkInlineRenderer>();
|
||||
if (inlineRenderer != null)
|
||||
{
|
||||
inlineRenderer.TryWriters.Remove(TryLinkInlineRenderer);
|
||||
inlineRenderer.TryWriters.Add(TryLinkInlineRenderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLinkInlineRenderer(HtmlRenderer renderer, LinkInline linkInline)
|
||||
{
|
||||
if (linkInline.Url == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Uri uri;
|
||||
// Only process absolute Uri
|
||||
if (!Uri.TryCreate(linkInline.Url, UriKind.RelativeOrAbsolute, out uri) || !uri.IsAbsoluteUri)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
RenderTargetAttribute(uri, renderer, linkInline);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RenderTargetAttribute(Uri uri, HtmlRenderer renderer, LinkInline linkInline)
|
||||
{
|
||||
|
||||
linkInline.SetAttributes(new HtmlAttributes() { Properties = new List<KeyValuePair<string, string>>() { new KeyValuePair<string, string>("target", "_blank"), new KeyValuePair<string, string>("rel", "noopener"), } });
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
48
TerribleDev.Blog.MarkdownPlugins/ImageRecorder.cs
Normal file
48
TerribleDev.Blog.MarkdownPlugins/ImageRecorder.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace TerribleDev.Blog.MarkdownPlugins
|
||||
{
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Markdig;
|
||||
using Markdig.Renderers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Renderers.Html.Inlines;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
public class ImageRecorder : IMarkdownExtension
|
||||
{
|
||||
private List<string> images;
|
||||
public ImageRecorder(List<string> images)
|
||||
{
|
||||
this.images = images;
|
||||
}
|
||||
public void Setup(MarkdownPipelineBuilder pipeline)
|
||||
{
|
||||
}
|
||||
|
||||
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
|
||||
{
|
||||
var htmlRenderer = renderer as HtmlRenderer;
|
||||
if (htmlRenderer != null)
|
||||
{
|
||||
var inlineRenderer = htmlRenderer.ObjectRenderers.FindExact<LinkInlineRenderer>();
|
||||
if (inlineRenderer != null)
|
||||
{
|
||||
inlineRenderer.TryWriters.Remove(TryLinkInlineRenderer);
|
||||
inlineRenderer.TryWriters.Add(TryLinkInlineRenderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLinkInlineRenderer(HtmlRenderer renderer, LinkInline linkInline)
|
||||
{
|
||||
if (linkInline.Url == null || !linkInline.IsImage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
this.images.Add(linkInline.Url);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.15.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E6C01762-AEB
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TerribleDev.Blog.Web", "src\TerribleDev.Blog.Web\TerribleDev.Blog.Web.csproj", "{BAA8662D-6D38-4811-A6FF-7A61D0C633D2}"
|
||||
EndProject
|
||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TerribleDev.Blog.Core", "src\TerribleDev.Blog.Core\TerribleDev.Blog.Core.fsproj", "{31876934-45BC-4C28-BBAF-0A50047345BB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerribleDev.Blog.MarkdownPlugins", "TerribleDev.Blog.MarkdownPlugins\TerribleDev.Blog.MarkdownPlugins.csproj", "{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -29,12 +33,38 @@ Global
|
||||
{BAA8662D-6D38-4811-A6FF-7A61D0C633D2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BAA8662D-6D38-4811-A6FF-7A61D0C633D2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BAA8662D-6D38-4811-A6FF-7A61D0C633D2}.Release|x86.Build.0 = Release|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{BAA8662D-6D38-4811-A6FF-7A61D0C633D2} = {E6C01762-AEBF-47C4-8D95-383504D8BC70}
|
||||
{31876934-45BC-4C28-BBAF-0A50047345BB} = {E6C01762-AEBF-47C4-8D95-383504D8BC70}
|
||||
{C3A2BFB5-64FC-4FCF-A26C-B4E4C67531B7} = {E6C01762-AEBF-47C4-8D95-383504D8BC70}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {CFA796F1-4389-452F-B224-E64C72E907C4}
|
||||
|
||||
85
src/TerribleDev.Blog.Core/Factories/BlogFactory.fs
Normal file
85
src/TerribleDev.Blog.Core/Factories/BlogFactory.fs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace TerribleDev.Blog.Core.Factories
|
||||
module BlogFactory =
|
||||
|
||||
open System.IO
|
||||
open YamlDotNet.Serialization
|
||||
open TerribleDev.Blog.Core.Models
|
||||
open TerribleDev.Blog.Core
|
||||
open Markdig
|
||||
open TerribleDev.Blog.MarkdownPlugins
|
||||
open Microsoft.AspNetCore.Html
|
||||
open System.Linq
|
||||
open System.Collections.Generic
|
||||
open fs
|
||||
open System
|
||||
|
||||
let fixTagName (tag:string) = tag.Replace(' ', '-').WithoutSpecialCharacters().ToLower()
|
||||
let mapImgUrlResolver (resolveUrl:string) =
|
||||
fun (imgUrl: string) -> if imgUrl.StartsWith('/') then imgUrl else sprintf "/%s/%s" resolveUrl imgUrl
|
||||
|
||||
|
||||
let getPosts (path) = Directory.EnumerateFiles(path, "*.md", SearchOption.TopDirectoryOnly)
|
||||
let parseYml (postText:string) =
|
||||
let split = postText.Split("---")
|
||||
match split with
|
||||
| [| _ |] -> raise(Exception("No yml found"))
|
||||
| [| yml; _|] -> DeserializerBuilder().Build().Deserialize(yml)
|
||||
| split when split.Length > 2 -> DeserializerBuilder().Build().Deserialize(split.[0])
|
||||
|
||||
|
||||
|
||||
let getMarkdownBuilder (imgRef) =
|
||||
MarkdownPipelineBuilder()
|
||||
.Use<TargetLinkExtension>()
|
||||
.Use<ImageRecorder>(new ImageRecorder(imgRef))
|
||||
.UseMediaLinks()
|
||||
.UseEmojiAndSmiley()
|
||||
.Build()
|
||||
let parsePost (postText:string, fileName:FileInfo, postSettings:PostSettings): Post =
|
||||
let mutable images = System.Collections.Generic.List<string>()
|
||||
let markdownBuilder = getMarkdownBuilder(images)
|
||||
//todo this function is a bit gross
|
||||
let markdownText = postText.Split("---") |> Seq.skip 1 |> String.concat ""
|
||||
let postContent = Markdown.ToHtml(markdownText, markdownBuilder);
|
||||
let postContentPlain = Markdown.ToPlainText(markdownText, markdownBuilder).Split("<!-- more -->") |> String.concat ""
|
||||
//todo pattern match
|
||||
let resolvedUrl = if System.String.IsNullOrWhiteSpace(postSettings.permalink) then fileName.Name.Split('.').[0].Replace(' ', '-').WithoutSpecialCharacters() else postSettings.permalink
|
||||
let summary = postContent.Split("<!-- more -->").[0];
|
||||
let postSummaryPlain = postContentPlain.Split("<!-- more -->").[0];
|
||||
let tags = match postSettings.tags with
|
||||
| null -> Seq.empty
|
||||
| x -> x |> Seq.map fixTagName
|
||||
let summaryPlainShort = match postContentPlain with
|
||||
| postContentPlain when postContentPlain.Length <= 147 -> postContentPlain
|
||||
| postContentPlain -> postContentPlain.Substring(0, 146) + "..."
|
||||
let mapImgUrlFromResolved = mapImgUrlResolver resolvedUrl
|
||||
let images = images |> Seq.distinct |> Seq.map mapImgUrlFromResolved |> Seq.toList
|
||||
{
|
||||
PublishDate = postSettings.date.ToUniversalTime();
|
||||
tags = tags |> Seq.toList
|
||||
Title = postSettings.title;
|
||||
Url = resolvedUrl;
|
||||
Content = HtmlString(postContent);
|
||||
Summary = HtmlString(summary);
|
||||
SummaryPlain = postSummaryPlain;
|
||||
SummaryPlainShort = summaryPlainShort;
|
||||
ContentPlain = postContentPlain;
|
||||
Images = images |> Seq.toList
|
||||
}
|
||||
let getAllPosts path =
|
||||
getPosts path
|
||||
|> Seq.map getFileInfo
|
||||
|> Seq.map(Async.map(fun (text, fileInfo) -> (text, fileInfo, parseYml(text))))
|
||||
|> Seq.map(Async.map(parsePost))
|
||||
|> Async.Parallel
|
||||
// todo stop this
|
||||
|> Async.RunSynchronously
|
||||
|> System.Collections.Generic.List
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
17
src/TerribleDev.Blog.Core/Models/Post.fs
Normal file
17
src/TerribleDev.Blog.Core/Models/Post.fs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace TerribleDev.Blog.Core.Models
|
||||
open System;
|
||||
open Microsoft.AspNetCore.Html;
|
||||
|
||||
type Post =
|
||||
{
|
||||
Url: string;
|
||||
Title: string;
|
||||
PublishDate: DateTime;
|
||||
Content: HtmlString;
|
||||
Summary: HtmlString;
|
||||
ContentPlain: string;
|
||||
SummaryPlain: string;
|
||||
SummaryPlainShort: string;
|
||||
tags : List<string>
|
||||
Images: List<string>
|
||||
}
|
||||
18
src/TerribleDev.Blog.Core/Models/PostSettings.fs
Normal file
18
src/TerribleDev.Blog.Core/Models/PostSettings.fs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace TerribleDev.Blog.Core.Models
|
||||
open System;
|
||||
open System.Collections.Generic
|
||||
|
||||
[<CLIMutable>]
|
||||
type PostSettings =
|
||||
{
|
||||
tags: List<string>;
|
||||
title: string
|
||||
permalink: string
|
||||
date: DateTime
|
||||
updated: DateTime
|
||||
id:string
|
||||
thumbnail_image:string
|
||||
thumbnailImage:string
|
||||
thumbnail_image_position:string
|
||||
layout:string
|
||||
}
|
||||
9
src/TerribleDev.Blog.Core/Models/Util.fs
Normal file
9
src/TerribleDev.Blog.Core/Models/Util.fs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace TerribleDev.Blog.Core.Models
|
||||
open Microsoft.SyndicationFeed
|
||||
open System
|
||||
|
||||
module Util =
|
||||
let ToSyndicationItem (x: Post) =
|
||||
let url = sprintf "https://blog.terribledev.io/%s" x.Url
|
||||
let publishDate : DateTimeOffset = (DateTimeOffset) x.PublishDate
|
||||
SyndicationItem(Title = x.Title, Description = x.Content.ToString(), Id = url, Published = publishDate)
|
||||
23
src/TerribleDev.Blog.Core/TerribleDev.Blog.Core.fsproj
Normal file
23
src/TerribleDev.Blog.Core/TerribleDev.Blog.Core.fsproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Util\fs.fs" />
|
||||
<Compile Include="Util\Constants.fs" />
|
||||
<Compile Include="Util\Async.fs" />
|
||||
<Compile Include="Models\PostSettings.fs" />
|
||||
<Compile Include="Models\Post.fs" />
|
||||
<Compile Include="Models\Util.fs" />
|
||||
<Compile Include="Factories\BlogFactory.fs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Html.Abstractions" Version="2.2.0" />
|
||||
<PackageReference Include="Markdig" Version="0.15.7" />
|
||||
<PackageReference Include="YamlDotNet" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\TerribleDev.Blog.MarkdownPlugins\TerribleDev.Blog.MarkdownPlugins.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
35
src/TerribleDev.Blog.Core/Util/Async.fs
Normal file
35
src/TerribleDev.Blog.Core/Util/Async.fs
Normal file
@@ -0,0 +1,35 @@
|
||||
module Async
|
||||
|
||||
let map f xAsync = async {
|
||||
// get the contents of xAsync
|
||||
let! x = xAsync
|
||||
// apply the function and lift the result
|
||||
return f x
|
||||
}
|
||||
|
||||
let retn x = async {
|
||||
// lift x to an Async
|
||||
return x
|
||||
}
|
||||
|
||||
let apply fAsync xAsync = async {
|
||||
// start the two asyncs in parallel
|
||||
let! fChild = Async.StartChild fAsync
|
||||
let! xChild = Async.StartChild xAsync
|
||||
|
||||
// wait for the results
|
||||
let! f = fChild
|
||||
let! x = xChild
|
||||
|
||||
// apply the function to the results
|
||||
return f x
|
||||
}
|
||||
|
||||
let bind f xAsync = async {
|
||||
// get the contents of xAsync
|
||||
let! x = xAsync
|
||||
// apply the function but don't lift the result
|
||||
// as f will return an Async
|
||||
return! f x
|
||||
}
|
||||
|
||||
8
src/TerribleDev.Blog.Core/Util/Constants.fs
Normal file
8
src/TerribleDev.Blog.Core/Util/Constants.fs
Normal file
@@ -0,0 +1,8 @@
|
||||
module Constants
|
||||
module Constants=
|
||||
|
||||
let MORE = "<!-- more -->"
|
||||
let YMLDIVIDER = "---"
|
||||
type Tokens =
|
||||
| MORE
|
||||
| YMLDIVIDER
|
||||
10
src/TerribleDev.Blog.Core/Util/fs.fs
Normal file
10
src/TerribleDev.Blog.Core/Util/fs.fs
Normal file
@@ -0,0 +1,10 @@
|
||||
module fs
|
||||
|
||||
open System.IO
|
||||
|
||||
let getFileInfo (filePath:string) =
|
||||
let fileInfo = FileInfo(filePath)
|
||||
async {
|
||||
let! text = File.ReadAllTextAsync(fileInfo.FullName) |> Async.AwaitTask
|
||||
return (text, fileInfo)
|
||||
}
|
||||
@@ -12,10 +12,10 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
{
|
||||
public class HomeController : Controller
|
||||
{
|
||||
public static List<IPost> postsAsList = new BlogFactory().GetAllPosts().OrderByDescending(a=>a.PublishDate).ToList();
|
||||
public static Dictionary<string, List<IPost>> tagToPost = postsAsList.Where(a=>a.tags != null)
|
||||
public static List<TerribleDev.Blog.Core.Models.Post> postsAsList = TerribleDev.Blog.Core.Factories.BlogFactory.getAllPosts("Posts").OrderByDescending(a => a.PublishDate).ToList();
|
||||
public static Dictionary<string, List<TerribleDev.Blog.Core.Models.Post>> tagToPost = postsAsList.Where(a=>a.tags != null)
|
||||
.Aggregate(
|
||||
new Dictionary<string, List<IPost>>(),
|
||||
new Dictionary<string, List<TerribleDev.Blog.Core.Models.Post>>(),
|
||||
(accum, item) => {
|
||||
foreach(var tag in item.tags)
|
||||
{
|
||||
@@ -25,19 +25,19 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
}
|
||||
else
|
||||
{
|
||||
accum[tag] = new List<IPost>() { item };
|
||||
accum[tag] = new List<TerribleDev.Blog.Core.Models.Post>() { item };
|
||||
}
|
||||
}
|
||||
return accum;
|
||||
});
|
||||
public static IDictionary<string, IPost> posts = postsAsList.ToDictionary(a => a.Url);
|
||||
public static IDictionary<int, List<IPost>> postsByPage = postsAsList.Aggregate(new Dictionary<int, List<IPost>>() { [1] = new List<IPost>() }, (accum, item) =>
|
||||
public static IDictionary<string, TerribleDev.Blog.Core.Models.Post> posts = postsAsList.ToDictionary(a => a.Url);
|
||||
public static IDictionary<int, List<TerribleDev.Blog.Core.Models.Post>> postsByPage = postsAsList.Aggregate(new Dictionary<int, List<TerribleDev.Blog.Core.Models.Post>>() { [1] = new List<TerribleDev.Blog.Core.Models.Post>() }, (accum, item) =>
|
||||
{
|
||||
var highestPage = accum.Keys.Max();
|
||||
var current = accum[highestPage].Count;
|
||||
if (current >= 10)
|
||||
{
|
||||
accum[highestPage + 1] = new List<IPost>() { item };
|
||||
accum[highestPage + 1] = new List<TerribleDev.Blog.Core.Models.Post>() { item };
|
||||
return accum;
|
||||
}
|
||||
accum[highestPage].Add(item);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace TerribleDev.Blog.Web.Controllers
|
||||
public class SeoController : Controller
|
||||
{
|
||||
public static DateTimeOffset publishDate = DateTimeOffset.UtcNow; // keep publish date in memory so we just return when the server was kicked
|
||||
public static IEnumerable<SyndicationItem> postsToSyndication = HomeController.postsAsList.Select(a => a.ToSyndicationItem()).ToList();
|
||||
public static IEnumerable<SyndicationItem> postsToSyndication = HomeController.postsAsList.Select(Core.Models.Util.ToSyndicationItem).ToList();
|
||||
[Route("/rss")]
|
||||
[Route("/rss.xml")]
|
||||
[ResponseCache(Duration = 7200)]
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using Microsoft.SyndicationFeed;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using TerribleDev.Blog.Web.Models;
|
||||
|
||||
namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public static class IPostExtensions
|
||||
{
|
||||
public static SyndicationItem ToSyndicationItem(this IPost x)
|
||||
{
|
||||
return new SyndicationItem()
|
||||
{
|
||||
Title = x.Title,
|
||||
Description = x.Content.ToString(),
|
||||
Id = $"https://blog.terribledev.io/{x.Url}",
|
||||
Published = x.PublishDate
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public static class StringExtension
|
||||
{
|
||||
public static string WithoutSpecialCharacters(this string str)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
foreach (char c in str)
|
||||
{
|
||||
if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == '-')
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using TerribleDev.Blog.Web.Models;
|
||||
using YamlDotNet.Serialization;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Markdig;
|
||||
using TerribleDev.Blog.Web.MarkExtension.TerribleDev.Blog.Web.ExternalLinkParser;
|
||||
|
||||
namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public class BlogFactory
|
||||
{
|
||||
public List<IPost> GetAllPosts()
|
||||
{
|
||||
// why didn't I use f# I'd have a pipe operator by now
|
||||
var posts = GetPosts();
|
||||
var postsAsText = posts.Select(GetFileText);
|
||||
return Task.WhenAll(postsAsText).Result.AsParallel().Select(b => ParsePost(b.text, b.fileInfo.Name)).ToList();
|
||||
}
|
||||
|
||||
private static async Task<(string text, FileInfo fileInfo)> GetFileText(string filePath)
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var text = await File.ReadAllTextAsync(fileInfo.FullName);
|
||||
return (text, fileInfo);
|
||||
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetPosts() => Directory.EnumerateFiles(Path.Combine(Directory.GetCurrentDirectory(), "Posts"), "*.md", SearchOption.TopDirectoryOnly);
|
||||
|
||||
public PostSettings ParseYaml(string ymlText)
|
||||
{
|
||||
var serializer = new DeserializerBuilder().Build();
|
||||
return serializer.Deserialize<PostSettings>(ymlText);
|
||||
|
||||
}
|
||||
public IPost ParsePost(string postText, string fileName)
|
||||
{
|
||||
var splitFile = postText.Split("---");
|
||||
var ymlRaw = splitFile[0];
|
||||
var markdownText = string.Join("", splitFile.Skip(1));
|
||||
List<string> postImages = new List<string>();
|
||||
var pipeline = new MarkdownPipelineBuilder()
|
||||
.Use<TargetLinkExtension>()
|
||||
.Use<ImageRecorder>(new ImageRecorder(ref postImages))
|
||||
.UseMediaLinks()
|
||||
.UseEmojiAndSmiley()
|
||||
.Build();
|
||||
var postContent = Markdown.ToHtml(markdownText, pipeline);
|
||||
var postContentPlain = String.Join("", Markdown.ToPlainText(markdownText, pipeline).Split("<!-- more -->"));
|
||||
var postSettings = ParseYaml(ymlRaw);
|
||||
var resolvedUrl = !string.IsNullOrWhiteSpace(postSettings.permalink) ? postSettings.permalink : fileName.Split('.')[0].Replace(' ', '-').WithoutSpecialCharacters();
|
||||
var summary = postContent.Split("<!-- more -->")[0];
|
||||
var postSummaryPlain = postContentPlain.Split("<!-- more -->")[0];
|
||||
return new Post()
|
||||
{
|
||||
PublishDate = postSettings.date.ToUniversalTime(),
|
||||
tags = postSettings.tags?.Select(a => a.Replace(' ', '-').WithoutSpecialCharacters().ToLower()).ToList() ?? new List<string>(),
|
||||
Title = postSettings.title,
|
||||
Url = resolvedUrl,
|
||||
Content = new HtmlString(postContent),
|
||||
Summary = new HtmlString(summary),
|
||||
SummaryPlain = postSummaryPlain,
|
||||
SummaryPlainShort = (postContentPlain.Length <= 147 ? postContentPlain : postContentPlain.Substring(0, 146)) + "...",
|
||||
ContentPlain = postContentPlain,
|
||||
Images = postImages.Distinct().Select(a => a.StartsWith('/') ? a : $"/{resolvedUrl}/{a}").ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using TerribleDev.Blog.Web.Models;
|
||||
|
||||
namespace TerribleDev.Blog.Web
|
||||
{
|
||||
public interface IBlogFactory
|
||||
{
|
||||
Task<IEnumerable<IPost>> GetAllPosts();
|
||||
IEnumerable<string> GetPosts();
|
||||
IPost ParsePost(string postText, string fileName);
|
||||
IPostSettings ParseYaml(string ymlText);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace TerribleDev.Blog.Web.MarkExtension
|
||||
{
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Markdig;
|
||||
using Markdig.Renderers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Renderers.Html.Inlines;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace TerribleDev.Blog.Web.ExternalLinkParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension for extending image Markdown links in case a video or an audio file is linked and output proper link.
|
||||
/// </summary>
|
||||
/// <seealso cref="Markdig.IMarkdownExtension" />
|
||||
public class TargetLinkExtension : IMarkdownExtension
|
||||
{
|
||||
|
||||
public void Setup(MarkdownPipelineBuilder pipeline)
|
||||
{
|
||||
}
|
||||
|
||||
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
|
||||
{
|
||||
var htmlRenderer = renderer as HtmlRenderer;
|
||||
if (htmlRenderer != null)
|
||||
{
|
||||
var inlineRenderer = htmlRenderer.ObjectRenderers.FindExact<LinkInlineRenderer>();
|
||||
if (inlineRenderer != null)
|
||||
{
|
||||
inlineRenderer.TryWriters.Remove(TryLinkInlineRenderer);
|
||||
inlineRenderer.TryWriters.Add(TryLinkInlineRenderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLinkInlineRenderer(HtmlRenderer renderer, LinkInline linkInline)
|
||||
{
|
||||
if (linkInline.Url == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Uri uri;
|
||||
// Only process absolute Uri
|
||||
if (!Uri.TryCreate(linkInline.Url, UriKind.RelativeOrAbsolute, out uri) || !uri.IsAbsoluteUri)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
RenderTargetAttribute(uri, renderer, linkInline);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RenderTargetAttribute(Uri uri, HtmlRenderer renderer, LinkInline linkInline)
|
||||
{
|
||||
|
||||
linkInline.SetAttributes(new HtmlAttributes() { Properties = new List<KeyValuePair<string, string>>() { new KeyValuePair<string, string>("target", "_blank"), new KeyValuePair<string, string>("rel", "noopener"), } });
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace TerribleDev.Blog.Web.MarkExtension
|
||||
{
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Markdig;
|
||||
using Markdig.Renderers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Renderers.Html.Inlines;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace TerribleDev.Blog.Web.ExternalLinkParser
|
||||
{
|
||||
public class ImageRecorder : IMarkdownExtension
|
||||
{
|
||||
private List<string> images = null;
|
||||
public ImageRecorder(ref List<string> images)
|
||||
{
|
||||
this.images = images;
|
||||
}
|
||||
public void Setup(MarkdownPipelineBuilder pipeline)
|
||||
{
|
||||
}
|
||||
|
||||
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
|
||||
{
|
||||
var htmlRenderer = renderer as HtmlRenderer;
|
||||
if (htmlRenderer != null)
|
||||
{
|
||||
var inlineRenderer = htmlRenderer.ObjectRenderers.FindExact<LinkInlineRenderer>();
|
||||
if (inlineRenderer != null)
|
||||
{
|
||||
inlineRenderer.TryWriters.Remove(TryLinkInlineRenderer);
|
||||
inlineRenderer.TryWriters.Add(TryLinkInlineRenderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLinkInlineRenderer(HtmlRenderer renderer, LinkInline linkInline)
|
||||
{
|
||||
if (linkInline.Url == null || !linkInline.IsImage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
this.images.Add(linkInline.Url);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public class GetTagViewModel
|
||||
{
|
||||
public IEnumerable<IPost> Posts { get; set; }
|
||||
public IEnumerable<TerribleDev.Blog.Core.Models.Post> Posts { get; set; }
|
||||
public string Tag { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public class HomeViewModel
|
||||
{
|
||||
public IEnumerable<IPost> Posts { get; set;}
|
||||
public IEnumerable<TerribleDev.Blog.Core.Models.Post> Posts { get; set;}
|
||||
public int Page { get; set; }
|
||||
public string NextUrl { get; set; }
|
||||
public string PreviousUrl { get; set; }
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using YamlDotNet.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public interface IPost
|
||||
{
|
||||
string Url { get; set; }
|
||||
string Title { get; set; }
|
||||
HtmlString Summary { get; set; }
|
||||
DateTime PublishDate { get; set; }
|
||||
HtmlString Content { get; set; }
|
||||
string ContentPlain { get; set; }
|
||||
string SummaryPlain { get; set; }
|
||||
string SummaryPlainShort { get; set; }
|
||||
IList<string> tags { get; set; }
|
||||
IList<string> Images { get; set;}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public interface IPostSettings
|
||||
{
|
||||
string id { get; set; }
|
||||
List<string> tags { get; set; }
|
||||
string title { get; set; }
|
||||
string permalink { get; set; }
|
||||
string thumbnailImage { get; set; }
|
||||
DateTimeOffset date { get; set; }
|
||||
DateTimeOffset updated { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
[DebuggerDisplay("{Title}")]
|
||||
public class Post : IPost
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Title { get; set; }
|
||||
public DateTime PublishDate { get; set; }
|
||||
public HtmlString Content { get; set; }
|
||||
public HtmlString Summary { get; set; }
|
||||
public string ContentPlain { get; set; }
|
||||
public string SummaryPlain { get; set; }
|
||||
public string SummaryPlainShort { get; set; }
|
||||
public IList<string> tags { get; set; }
|
||||
public IList<string> Images { get; set;}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using Microsoft.AspNetCore.Html;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public class PostModel
|
||||
{
|
||||
public HtmlString Content { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Models
|
||||
{
|
||||
public class PostSettings
|
||||
{
|
||||
public List<string> tags { get; set; }
|
||||
public string title { get; set; }
|
||||
public string permalink { get; set; }
|
||||
public DateTime date { get; set; }
|
||||
public DateTime updated { get; set; }
|
||||
public string id { get; set; }
|
||||
public string thumbnail_image { get; set; }
|
||||
public string thumbnailImage { get; set; }
|
||||
public string thumbnail_image_position { get; set; }
|
||||
public string layout { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace TerribleDev.Blog.Web.Taghelpers
|
||||
{
|
||||
[HtmlTargetElement("desktopOnly", TagStructure = TagStructure.NormalOrSelfClosing)]
|
||||
public class DesktopTagHelper : TagHelper
|
||||
{
|
||||
static Regex MobileCheck = new Regex(@"(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
|
||||
static ConcurrentDictionary<string, bool> CachedChecks = new ConcurrentDictionary<string, bool>();
|
||||
public string UserAgent { get; set; }
|
||||
public override void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
output.TagName = null;
|
||||
if (string.IsNullOrEmpty(UserAgent))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var shouldRender = true;
|
||||
if(CachedChecks.TryGetValue(UserAgent, out var cacheResult))
|
||||
{
|
||||
shouldRender = cacheResult;
|
||||
}
|
||||
else
|
||||
{
|
||||
var isMobile = MobileCheck.IsMatch(UserAgent);
|
||||
shouldRender = !isMobile;
|
||||
CachedChecks.TryAdd(UserAgent, !isMobile);
|
||||
}
|
||||
if(!shouldRender)
|
||||
{
|
||||
output.SuppressOutput();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,12 @@
|
||||
<Content Include="Posts\*.md" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\TerribleDev.Blog.MarkdownPlugins\TerribleDev.Blog.MarkdownPlugins.csproj" />
|
||||
<ProjectReference Include="..\TerribleDev.Blog.Core\TerribleDev.Blog.Core.fsproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="Factories\BlogFactory.cs" />
|
||||
<Compile Remove="Extensions\IPostExtensions.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model IPost
|
||||
@model TerribleDev.Blog.Core.Models.Post
|
||||
@{
|
||||
ViewData["Title"] = "Post";
|
||||
ViewData["HideNav"] = true;
|
||||
@@ -23,10 +23,10 @@
|
||||
<meta property="og:image" content="https://www.gravatar.com/avatar/333e3cea32cd17ff2007d131df336061?s=640" />
|
||||
@foreach(var image in Model.Images.Take(6))
|
||||
{
|
||||
<meta property="og:image" content="https://blog.terribledev.io@(image)">
|
||||
<meta property="og:image" content="https://blog.terribledev.io@(image)">
|
||||
}
|
||||
@if(Model.Images.Count > 0)
|
||||
@if(Model.Images.Length > 0)
|
||||
{
|
||||
<meta name="twitter:image" content="https://blog.terribledev.io@(Model.Images[0])">
|
||||
<meta name="twitter:image" content="https://blog.terribledev.io@(Model.Images[0])">
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model IPost
|
||||
@model TerribleDev.Blog.Core.Models.Post
|
||||
|
||||
<article itemprop="blogPost">
|
||||
<h1 itemprop="headline" class="headline">@Model.Title</h1>
|
||||
<time class="headlineSubtext" itemprop="datePublished" content="@Model.PublishDate.ToString()">@Model.PublishDate.ToString("D")</time>
|
||||
@Model.Content
|
||||
@if (Model.tags.Count > 0)
|
||||
@if (Model.tags.Length > 0)
|
||||
{
|
||||
<div>
|
||||
<span>Tagged In:</span><br />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model IPost
|
||||
@model TerribleDev.Blog.Core.Models.Post
|
||||
|
||||
<article class="btmRule">
|
||||
<h3 itemprop="headline" class="headline"><a href="/@Model.Url/" class="link-unstyled">@Model.Title</a></h3>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@model Dictionary<string, List<IPost>>
|
||||
@model Dictionary<string, List<TerribleDev.Blog.Core.Models.Post>>
|
||||
@{
|
||||
ViewData["Title"] = "all-tags";
|
||||
}
|
||||
<h2>All Tags</h2>
|
||||
<cache>
|
||||
@foreach (var tag in Model.Keys)
|
||||
{
|
||||
<a href="/tag/@tag/" class="btn block">@tag</a>
|
||||
}
|
||||
@foreach (var tag in Model.Keys)
|
||||
{
|
||||
<a href="/tag/@tag/" class="btn block">@tag</a>
|
||||
}
|
||||
</cache>
|
||||
@section Head {
|
||||
<partial name="StockMeta" />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
@using TerribleDev.Blog.Web
|
||||
@using TerribleDev.Blog.Web.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper *, TerribleDev.Blog.Web
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
Reference in New Issue
Block a user