Skip to content

Commit

Permalink
feat: add blog details page
Browse files Browse the repository at this point in the history
chore: switch to serilog
feat: add highlightjs
feat: add markdown service
  • Loading branch information
AngelMunoz committed Oct 27, 2024
1 parent 2eb9254 commit 4259ffd
Show file tree
Hide file tree
Showing 21 changed files with 1,135 additions and 26 deletions.
34 changes: 24 additions & 10 deletions Controllers/Public.fs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,39 @@ let postDetail(path: string) : EndpointHandler =
fun ctx -> task {
let env = ctx.GetLayoutEnv()
let posts = ctx.GetService<PostsService>()
let markdown = ctx.GetService<MarkdownService>()

let idx = path.LastIndexOf("_")

let title = path.Substring(0, idx) |> System.Web.HttpUtility.UrlDecode
let title = path.Substring(0, idx) |> PermaPath.fromUrl
let slug = path.Substring(idx + 1)

let! post = posts.findPostByTitleAndSlug(title, slug)


return!
match post with
| Some(post) ->
jsonChunked
{|
post with
status = post.status.AsString
|}
ctx
| None -> (setStatusCode 404 >=> text "Not Found") ctx

| Some post ->


let postParams: Layout.PostPageParams = {
title = post.title
authorName = post.author.name
publishedAt =
post.publishedAt
|> Option.map(_.ToShortDateString())
|> Option.defaultValue ""
summary = post.content[0..55] |> markdown.toText
content = post.content |> markdown.toHtml
}

sendHox (Public.post(postParams, env)) ctx
| None ->
let message =
$"We were unable to find this blog post. Please check the URL and try again. '{title}_{slug}'"

let responseContent = Layout.Layout.NotFound(title, message, env)
(setStatusCode 404 >=> sendHox responseContent) ctx
}

let about: EndpointHandler =
Expand Down
10 changes: 10 additions & 0 deletions Extensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,13 @@ let inline CsrfPatch routes =

let inline CsrfDelete routes =
applyBefore verifyAntiforgery (DELETE routes)


module PermaPath =
open System.Web

let toUrl(path: string) =
path.Replace("#", "~~") |> HttpUtility.UrlEncode

let fromUrl(path: string) =
path |> HttpUtility.UrlDecode |> _.Replace("~~", "#")
5 changes: 4 additions & 1 deletion Openapo.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@
<Compile Include="Views/Layout.fs" />
<Compile Include="Views/Public.fs" />
<Compile Include="Views/Posts.fs" />
<Compile Include="Services\Markdown.fs" />
<Compile Include="Services/Database.fs" />
<Compile Include="Controllers/Public.fs" />
<Compile Include="Controllers/Posts.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Hox" Version="1.3.0" />
<PackageReference Include="Markdig" Version="0.38.0" />
<PackageReference Include="Oxpecker" Version="0.14.1" />
<PackageReference Include="Npgsql" Version="8.0.5" />
<PackageReference Include="Donald" Version="10.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
</ItemGroup>
</Project>
</Project>
23 changes: 20 additions & 3 deletions Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,36 @@ open Oxpecker
open Microsoft.Extensions.Configuration
open Npgsql
open Services
open Markdig

open Serilog

Log.Logger <- LoggerConfiguration().WriteTo.Console().CreateLogger()

let builder = WebApplication.CreateBuilder(Environment.GetCommandLineArgs())

builder.Services
.AddSingleton<MarkdownPipeline>(fun _ ->
MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UsePreciseSourceLocation()
.Build())
.AddScoped<MarkdownService>(fun services ->
Markdown.getMarkdownService(services.GetService<MarkdownPipeline>()))
.AddSingleton<NpgsqlDataSource>(fun services ->
Database.getDataSource(services.GetService<IConfiguration>()))
.AddSingleton<ConnectionFactory>(fun services ->
Database.getConnectionFactory(services.GetService<NpgsqlDataSource>()))
.AddScoped<PostsService>(fun services ->
Posts.getPostService(services.GetService<ConnectionFactory>()))
Posts.getPostService(
services.GetService<ConnectionFactory>(),
services.GetService<MarkdownService>()
))
.AddScoped<AuthorsService>(fun services ->
Authors.getAuthorService(services.GetService<ConnectionFactory>()))
|> ignore<IServiceCollection>

builder.Services.AddAntiforgery().AddRouting().AddOxpecker()
builder.Services.AddAntiforgery().AddRouting().AddOxpecker().AddSerilog()
|> ignore<IServiceCollection>

let app = builder.Build()
Expand All @@ -34,7 +49,9 @@ app
route "/" Controllers.Public.index
route "/about" Controllers.Public.about
route "/posts" Controllers.Posts.newPost
routef "/posts/{%s}" Controllers.Public.postDetail
routef
"/posts/{%s:regex(^.+_(\\d{{4}}-\\d{{2}}-\\d{{2}}-\\d+)$)}"
Controllers.Public.postDetail
]
CsrfPost [ route "/posts" Controllers.Posts.savePost ]
]
Expand Down
10 changes: 7 additions & 3 deletions Services/Database.fs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,11 @@ module Queries =


module Posts =
let getPostService(connectionFactory: ConnectionFactory) =
open Services

let getPostService
(connectionFactory: ConnectionFactory, markdown: MarkdownService)
=
{ new PostsService with
member _.findPostById id = task {
use! connection = connectionFactory.CreateConnectionAsync()
Expand Down Expand Up @@ -334,10 +338,10 @@ module Posts =
|> List.map(fun sLike -> {
id = sLike.id
title = sLike.title
summary = sLike.content[0..100]
summary = sLike.content[0..100] |> markdown.toText
authorName = sLike.authorName
permanentPath =
$"/posts/{HttpUtility.HtmlEncode(sLike.title)}_{sLike.slug}"
$"/posts/{PermaPath.toUrl sLike.title}_{sLike.slug}"
publishedAt = sLike.publishedAt
})

Expand Down
19 changes: 19 additions & 0 deletions Services/Markdown.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Services


open Markdig

type MarkdownService =
abstract toText: string -> string
abstract toHtml: string -> string


module Markdown =

let getMarkdownService(markdownConfig: MarkdownPipeline) : MarkdownService =
{ new MarkdownService with
member _.toText text =
Markdown.ToPlainText(text, markdownConfig)

member _.toHtml text = Markdown.ToHtml(text, markdownConfig)
}
79 changes: 78 additions & 1 deletion Views/Layout.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,38 @@ module Views.Layout
open Hox


type PostPageParams = {
title: string
authorName: string
publishedAt: string
content: string
summary: string
}


[<AutoOpen>]
type Layout =

static member Page
(
content: Core.Node,
?title: string,
?scripts: Core.Node,
?styles: Core.Node,
?env: LayoutEnv
) =
let scripts = scripts |> Option.defaultValue empty
let styles = styles |> Option.defaultValue empty

let title =
title
|> Option.map(fun title ->
if System.String.IsNullOrWhiteSpace title then
"Openapo"
else
title)
|> Option.defaultValue ""

let env = env |> Option.defaultValue { isDevelopment = true }

h(
Expand All @@ -23,7 +43,7 @@ type Layout =
"head",
h "meta[charset=utf-8]",
h "meta[name=viewport][content=width=device-width, initial-scale=1.0]",
h("title", text "Hox"),
h("title", title),
h("link[rel=stylesheet]")
.attr(
"href",
Expand Down Expand Up @@ -57,3 +77,60 @@ type Layout =
scripts
)
)

static member inline Post
(
post: PostPageParams,
?sidenav: Core.Node,
?extraMeta: Core.Node,
?env: LayoutEnv
) =

Page(
h(
"main.uk-container",
h(
"article.uk-article",
h("h1.uk-article-title", post.title),
h(
"p.uk-article-meta",
$"Written by {post.authorName} on {post.publishedAt}"
),
fragment(
[
match extraMeta with
| Some extraMeta -> h("section.extra-meta", extraMeta)
| None -> empty
match sidenav with
| Some sidenav -> h("aside.sidenav", sidenav)
| None -> empty
]
),
h(
"section.uk-card.uk-card-default
.uk-card-body.uk-card-hover",
raw post.content
)
)
),
scripts =
fragment(
h("script[src=/libs/highlight/js/highlight.min.js]"),
h("script[src=/js/highlight.js][type=module]")
),
styles = h("link[rel=stylesheet][href=/libs/highlight/css/nord.min.css]"),
?env = env
)

static member inline NotFound(title, message: string, ?env: LayoutEnv) =
Page(
h(
"main.uk-container",
h(
"section.uk-section.uk-section-default",
h("p.uk-text-large", message)
)
),
title = title,
?env = env
)
7 changes: 5 additions & 2 deletions Views/Public.fs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type Public = class end

type Public with

static member index(postListSummary, ?env: LayoutEnv) =
static member inline index(postListSummary, ?env: LayoutEnv) =
Page(
h(
"article.uk-padding",
Expand All @@ -62,8 +62,11 @@ type Public with
?env = env
)

static member inline post(post: PostPageParams, ?env: LayoutEnv) =
Post(post, ?env = env)

static member about(?env: LayoutEnv) =

static member inline about(?env: LayoutEnv) =

Page(
h(
Expand Down
6 changes: 0 additions & 6 deletions appsettings.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DatabaseConnection": "User ID=posgres;Password=posgres;Host=localhost;Port=5432;Database=openapo"
}
Expand Down
13 changes: 13 additions & 0 deletions wwwroot/js/highlight.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import "/libs/highlight/js/languages/bash.min.js";
import "/libs/highlight/js/languages/csharp.min.js";
import "/libs/highlight/js/languages/css.min.js";
import "/libs/highlight/js/languages/fsharp.min.js";
import "/libs/highlight/js/languages/javascript.min.js";
import "/libs/highlight/js/languages/json.min.js";
import "/libs/highlight/js/languages/pgsql.min.js";
import "/libs/highlight/js/languages/plaintext.min.js";
import "/libs/highlight/js/languages/sql.min.js";

document.addEventListener("DOMContentLoaded", function () {
hljs.highlightAll();
});
1 change: 1 addition & 0 deletions wwwroot/libs/highlight/css/nord.min.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 4259ffd

Please sign in to comment.