V2 #1

Merged
danieljsummers merged 102 commits from v2 into main 2022-06-23 00:35:12 +00:00
83 changed files with 2495 additions and 19 deletions
Showing only changes of commit 62f7896621 - Show all commits

View File

@ -0,0 +1,73 @@
/// JSON.NET converters for discriminated union types
[<RequireQualifiedAccess>]
module MyWebLog.JsonConverters
open MyWebLog
open Newtonsoft.Json
open System
type CategoryIdConverter () =
inherit JsonConverter<CategoryId> ()
override _.WriteJson (writer : JsonWriter, value : CategoryId, _ : JsonSerializer) =
writer.WriteValue (CategoryId.toString value)
override _.ReadJson (reader : JsonReader, _ : Type, _ : CategoryId, _ : bool, _ : JsonSerializer) =
(string >> CategoryId) reader.Value
type CommentIdConverter () =
inherit JsonConverter<CommentId> ()
override _.WriteJson (writer : JsonWriter, value : CommentId, _ : JsonSerializer) =
writer.WriteValue (CommentId.toString value)
override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) =
(string >> CommentId) reader.Value
type PermalinkConverter () =
inherit JsonConverter<Permalink> ()
override _.WriteJson (writer : JsonWriter, value : Permalink, _ : JsonSerializer) =
writer.WriteValue (Permalink.toString value)
override _.ReadJson (reader : JsonReader, _ : Type, _ : Permalink, _ : bool, _ : JsonSerializer) =
(string >> Permalink) reader.Value
type PageIdConverter () =
inherit JsonConverter<PageId> ()
override _.WriteJson (writer : JsonWriter, value : PageId, _ : JsonSerializer) =
writer.WriteValue (PageId.toString value)
override _.ReadJson (reader : JsonReader, _ : Type, _ : PageId, _ : bool, _ : JsonSerializer) =
(string >> PageId) reader.Value
type PostIdConverter () =
inherit JsonConverter<PostId> ()
override _.WriteJson (writer : JsonWriter, value : PostId, _ : JsonSerializer) =
writer.WriteValue (PostId.toString value)
override _.ReadJson (reader : JsonReader, _ : Type, _ : PostId, _ : bool, _ : JsonSerializer) =
(string >> PostId) reader.Value
type WebLogIdConverter () =
inherit JsonConverter<WebLogId> ()
override _.WriteJson (writer : JsonWriter, value : WebLogId, _ : JsonSerializer) =
writer.WriteValue (WebLogId.toString value)
override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogId, _ : bool, _ : JsonSerializer) =
(string >> WebLogId) reader.Value
type WebLogUserIdConverter () =
inherit JsonConverter<WebLogUserId> ()
override _.WriteJson (writer : JsonWriter, value : WebLogUserId, _ : JsonSerializer) =
writer.WriteValue (WebLogUserId.toString value)
override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogUserId, _ : bool, _ : JsonSerializer) =
(string >> WebLogUserId) reader.Value
open Microsoft.FSharpLu.Json
/// All converters to use for data conversion
let all () : JsonConverter seq =
seq {
CategoryIdConverter ()
CommentIdConverter ()
PermalinkConverter ()
PageIdConverter ()
PostIdConverter ()
WebLogIdConverter ()
WebLogUserIdConverter ()
// Handles DUs with no associated data, as well as option fields
CompactUnionJsonConverter ()
}

298
src/MyWebLog.Data/Data.fs Normal file
View File

@ -0,0 +1,298 @@
[<RequireQualifiedAccess>]
module MyWebLog.Data
/// Table names
[<RequireQualifiedAccess>]
module private Table =
/// The category table
let Category = "Category"
/// The comment table
let Comment = "Comment"
/// The page table
let Page = "Page"
/// The post table
let Post = "Post"
/// The web log table
let WebLog = "WebLog"
/// The web log user table
let WebLogUser = "WebLogUser"
/// A list of all tables
let all = [ Category; Comment; Page; Post; WebLog; WebLogUser ]
/// Functions to assist with retrieving data
[<AutoOpen>]
module Helpers =
open RethinkDb.Driver
open RethinkDb.Driver.Net
open System.Threading.Tasks
/// Shorthand for the ReQL starting point
let r = RethinkDB.R
/// Verify that the web log ID matches before returning an item
let verifyWebLog<'T> webLogId (prop : 'T -> WebLogId) (f : IConnection -> Task<'T option>) =
fun conn -> task {
match! f conn with Some it when (prop it) = webLogId -> return Some it | _ -> return None
}
open RethinkDb.Driver.FSharp
open Microsoft.Extensions.Logging
module Startup =
/// Ensure field indexes exist, as well as special indexes for selected tables
let private ensureIndexes (log : ILogger) conn table fields = task {
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
for field in fields do
match indexes |> List.contains field with
| true -> ()
| false ->
log.LogInformation($"Creating index {table}.{field}...")
let! _ = rethink { withTable table; indexCreate field; write; withRetryOnce conn }
()
// Post and page need index by web log ID and permalink
match [ Table.Page; Table.Post ] |> List.contains table with
| true ->
match indexes |> List.contains "permalink" with
| true -> ()
| false ->
log.LogInformation($"Creating index {table}.permalink...")
let! _ =
rethink {
withTable table
indexCreate "permalink" (fun row -> r.Array(row.G "webLogId", row.G "permalink"))
write
withRetryOnce conn
}
()
| false -> ()
// Users log on with e-mail
match Table.WebLogUser = table with
| true ->
match indexes |> List.contains "logOn" with
| true -> ()
| false ->
log.LogInformation($"Creating index {table}.logOn...")
let! _ =
rethink {
withTable table
indexCreate "logOn" (fun row -> r.Array(row.G "webLogId", row.G "email"))
write
withRetryOnce conn
}
()
| false -> ()
}
/// Ensure all necessary tables and indexes exist
let ensureDb (config : DataConfig) (log : ILogger) conn = task {
let! dbs = rethink<string list> { dbList; result; withRetryOnce conn }
match dbs |> List.contains config.Database with
| true -> ()
| false ->
log.LogInformation($"Creating database {config.Database}...")
let! _ = rethink { dbCreate config.Database; write; withRetryOnce conn }
()
let! tables = rethink<string list> { tableList; result; withRetryOnce conn }
for tbl in Table.all do
match tables |> List.contains tbl with
| true -> ()
| false ->
log.LogInformation($"Creating table {tbl}...")
let! _ = rethink { tableCreate tbl; write; withRetryOnce conn }
()
let makeIdx = ensureIndexes log conn
do! makeIdx Table.Category [ "webLogId" ]
do! makeIdx Table.Comment [ "postId" ]
do! makeIdx Table.Page [ "webLogId"; "authorId" ]
do! makeIdx Table.Post [ "webLogId"; "authorId" ]
do! makeIdx Table.WebLog [ "urlBase" ]
do! makeIdx Table.WebLogUser [ "webLogId" ]
}
/// Functions to manipulate categories
module Category =
/// Count all categories for a web log
let countAll (webLogId : WebLogId) =
rethink<int> {
withTable Table.Category
getAll [ webLogId ] (nameof webLogId)
count
result
withRetryDefault
}
/// Count top-level categories for a web log
let countTopLevel (webLogId : WebLogId) =
rethink<int> {
withTable Table.Category
getAll [ webLogId ] (nameof webLogId)
filter "parentId" None
count
result
withRetryDefault
}
/// Functions to manipulate pages
module Page =
/// Count all pages for a web log
let countAll (webLogId : WebLogId) =
rethink<int> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
count
result
withRetryDefault
}
/// Count listed pages for a web log
let countListed (webLogId : WebLogId) =
rethink<int> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
filter "showInPageList" true
count
result
withRetryDefault
}
/// Retrieve all pages for a web log
let findAll (webLogId : WebLogId) =
rethink<Page list> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
without [ "priorPermalinks", "revisions" ]
result
withRetryDefault
}
/// Find a page by its ID
let findById (pageId : PageId) webLogId =
rethink<Page> {
withTable Table.Page
get pageId
without [ "priorPermalinks", "revisions" ]
resultOption
withRetryDefault
}
|> verifyWebLog webLogId (fun it -> it.webLogId)
/// Find a page by its permalink
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Page> {
withTable Table.Page
getAll [ r.Array (webLogId, permalink) ] (nameof permalink)
without [ "priorPermalinks", "revisions" ]
limit 1
resultOption
withRetryDefault
}
/// Find a page by its ID (including permalinks and revisions)
let findByFullId (pageId : PageId) webLogId =
rethink<Page> {
withTable Table.Page
get pageId
resultOption
withRetryDefault
}
|> verifyWebLog webLogId (fun it -> it.webLogId)
/// Find a list of pages (displayed in admin area)
let findPageOfPages (webLogId : WebLogId) pageNbr =
rethink<Page list> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
without [ "priorPermalinks", "revisions" ]
orderBy "title"
skip ((pageNbr - 1) * 25)
limit 25
result
withRetryDefault
}
/// Functions to manipulate posts
module Post =
/// Count posts for a web log by their status
let countByStatus (status : PostStatus) (webLogId : WebLogId) =
rethink<int> {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
filter "status" status
count
result
withRetryDefault
}
/// Find a post by its permalink
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Post> {
withTable Table.Post
getAll [ r.Array(permalink, webLogId) ] (nameof permalink)
without [ "priorPermalinks", "revisions" ]
limit 1
resultOption
withRetryDefault
}
/// Find posts to be displayed on a page
let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage =
rethink<Post list> {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
filter "status" Published
without [ "priorPermalinks", "revisions" ]
orderBy "publishedOn"
skip ((pageNbr - 1) * postsPerPage)
limit postsPerPage
result
withRetryDefault
}
/// Functions to manipulate web logs
module WebLog =
/// Retrieve web log details by the URL base
let findByHost (url : string) =
rethink<WebLog> {
withTable Table.WebLog
getAll [ url ] "urlBase"
limit 1
resultOption
withRetryDefault
}
/// Update web log settings
let updateSettings (webLog : WebLog) =
rethink {
withTable Table.WebLog
get webLog.id
update [
"name", webLog.name
"subtitle", webLog.subtitle
"defaultPage", webLog.defaultPage
"postsPerPage", webLog.postsPerPage
"timeZone", webLog.timeZone
]
write
withRetryDefault
ignoreResult
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\RethinkDb.Driver.FSharp\src\RethinkDb.Driver.FSharp\RethinkDb.Driver.FSharp.fsproj" />
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="*" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
</ItemGroup>
<ItemGroup>
<Compile Include="Converters.fs" />
<Compile Include="Data.fs" />
</ItemGroup>
</Project>

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>

View File

@ -0,0 +1,309 @@
namespace MyWebLog
open System
/// A category under which a post may be identfied
[<CLIMutable; NoComparison; NoEquality>]
type Category =
{ /// The ID of the category
id : CategoryId
/// The ID of the web log to which the category belongs
webLogId : WebLogId
/// The displayed name
name : string
/// The slug (used in category URLs)
slug : string
/// A longer description of the category
description : string option
/// The parent ID of this category (if a subcategory)
parentId : CategoryId option
}
/// Functions to support categories
module Category =
/// An empty category
let empty =
{ id = CategoryId.empty
webLogId = WebLogId.empty
name = ""
slug = ""
description = None
parentId = None
}
/// A comment on a post
[<CLIMutable; NoComparison; NoEquality>]
type Comment =
{ /// The ID of the comment
id : CommentId
/// The ID of the post to which this comment applies
postId : PostId
/// The ID of the comment to which this comment is a reply
inReplyToId : CommentId option
/// The name of the commentor
name : string
/// The e-mail address of the commentor
email : string
/// The URL of the commentor's personal website
url : string option
/// The status of the comment
status : CommentStatus
/// When the comment was posted
postedOn : DateTime
/// The text of the comment
text : string
}
/// Functions to support comments
module Comment =
/// An empty comment
let empty =
{ id = CommentId.empty
postId = PostId.empty
inReplyToId = None
name = ""
email = ""
url = None
status = Pending
postedOn = DateTime.UtcNow
text = ""
}
/// A page (text not associated with a date/time)
[<CLIMutable; NoComparison; NoEquality>]
type Page =
{ /// The ID of this page
id : PageId
/// The ID of the web log to which this page belongs
webLogId : WebLogId
/// The ID of the author of this page
authorId : WebLogUserId
/// The title of the page
title : string
/// The link at which this page is displayed
permalink : Permalink
/// When this page was published
publishedOn : DateTime
/// When this page was last updated
updatedOn : DateTime
/// Whether this page shows as part of the web log's navigation
showInPageList : bool
/// The template to use when rendering this page
template : string option
/// The current text of the page
text : string
/// Permalinks at which this page may have been previously served (useful for migrated content)
priorPermalinks : string list
/// Revisions of this page
revisions : Revision list
}
/// Functions to support pages
module Page =
/// An empty page
let empty =
{ id = PageId.empty
webLogId = WebLogId.empty
authorId = WebLogUserId.empty
title = ""
permalink = Permalink.empty
publishedOn = DateTime.MinValue
updatedOn = DateTime.MinValue
showInPageList = false
template = None
text = ""
priorPermalinks = []
revisions = []
}
/// A web log post
[<CLIMutable; NoComparison; NoEquality>]
type Post =
{ /// The ID of this post
id : PostId
/// The ID of the web log to which this post belongs
webLogId : WebLogId
/// The ID of the author of this post
authorId : WebLogUserId
/// The status
status : PostStatus
/// The title
title : string
/// The link at which the post resides
permalink : Permalink
/// The instant on which the post was originally published
publishedOn : DateTime option
/// The instant on which the post was last updated
updatedOn : DateTime
/// The text of the post in HTML (ready to display) format
text : string
/// The Ids of the categories to which this is assigned
categoryIds : CategoryId list
/// The tags for the post
tags : string list
/// Permalinks at which this post may have been previously served (useful for migrated content)
priorPermalinks : Permalink list
/// The revisions for this post
revisions : Revision list
}
/// Functions to support posts
module Post =
/// An empty post
let empty =
{ id = PostId.empty
webLogId = WebLogId.empty
authorId = WebLogUserId.empty
status = Draft
title = ""
permalink = Permalink.empty
publishedOn = None
updatedOn = DateTime.MinValue
text = ""
categoryIds = []
tags = []
priorPermalinks = []
revisions = []
}
/// A web log
[<CLIMutable; NoComparison; NoEquality>]
type WebLog =
{ /// The ID of the web log
id : WebLogId
/// The name of the web log
name : string
/// A subtitle for the web log
subtitle : string option
/// The default page ("posts" or a page Id)
defaultPage : string
/// The number of posts to display on pages of posts
postsPerPage : int
/// The path of the theme (within /views/themes)
themePath : string
/// The URL base
urlBase : string
/// The time zone in which dates/times should be displayed
timeZone : string
}
/// Functions to support web logs
module WebLog =
/// An empty set of web logs
let empty =
{ id = WebLogId.empty
name = ""
subtitle = None
defaultPage = ""
postsPerPage = 10
themePath = "Default"
urlBase = ""
timeZone = ""
}
/// Convert a permalink to an absolute URL
let absoluteUrl webLog = function Permalink link -> $"{webLog.urlBase}{link}"
/// A user of the web log
[<CLIMutable; NoComparison; NoEquality>]
type WebLogUser =
{ /// The ID of the user
id : WebLogUserId
/// The ID of the web log to which this user belongs
webLogId : WebLogId
/// The user name (e-mail address)
userName : string
/// The user's first name
firstName : string
/// The user's last name
lastName : string
/// The user's preferred name
preferredName : string
/// The hash of the user's password
passwordHash : string
/// Salt used to calculate the user's password hash
salt : Guid
/// The URL of the user's personal site
url : string option
/// The user's authorization level
authorizationLevel : AuthorizationLevel
}
/// Functions to support web log users
module WebLogUser =
/// An empty web log user
let empty =
{ id = WebLogUserId.empty
webLogId = WebLogId.empty
userName = ""
firstName = ""
lastName = ""
preferredName = ""
passwordHash = ""
salt = Guid.Empty
url = None
authorizationLevel = User
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="SupportTypes.fs" />
<Compile Include="DataTypes.fs" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,181 @@
namespace MyWebLog
open System
/// Support functions for domain definition
[<AutoOpen>]
module private Helpers =
/// Create a new ID (short GUID)
// https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID
let newId() =
Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..22]
/// An identifier for a category
type CategoryId = CategoryId of string
/// Functions to support category IDs
module CategoryId =
/// An empty category ID
let empty = CategoryId ""
/// Convert a category ID to a string
let toString = function CategoryId ci -> ci
/// Create a new category ID
let create () = CategoryId (newId ())
/// An identifier for a comment
type CommentId = CommentId of string
/// Functions to support comment IDs
module CommentId =
/// An empty comment ID
let empty = CommentId ""
/// Convert a comment ID to a string
let toString = function CommentId ci -> ci
/// Create a new comment ID
let create () = CommentId (newId ())
/// Statuses for post comments
type CommentStatus =
/// The comment is approved
| Approved
/// The comment has yet to be approved
| Pending
/// The comment was unsolicited and unwelcome
| Spam
/// The source format for a revision
type RevisionSource =
/// Markdown text
| Markdown
/// HTML
| Html
/// A revision of a page or post
[<CLIMutable; NoComparison; NoEquality>]
type Revision =
{ /// When this revision was saved
asOf : DateTime
/// The source language (Markdown or HTML)
sourceType : RevisionSource
/// The text of the revision
text : string
}
/// Functions to support revisions
module Revision =
/// An empty revision
let empty =
{ asOf = DateTime.UtcNow
sourceType = Html
text = ""
}
/// A permanent link
type Permalink = Permalink of string
/// Functions to support permalinks
module Permalink =
/// An empty permalink
let empty = Permalink ""
/// Convert a permalink to a string
let toString = function Permalink p -> p
/// An identifier for a page
type PageId = PageId of string
/// Functions to support page IDs
module PageId =
/// An empty page ID
let empty = PageId ""
/// Convert a page ID to a string
let toString = function PageId pi -> pi
/// Create a new page ID
let create () = PageId (newId ())
/// Statuses for posts
type PostStatus =
/// The post should not be publicly available
| Draft
/// The post is publicly viewable
| Published
/// An identifier for a post
type PostId = PostId of string
/// Functions to support post IDs
module PostId =
/// An empty post ID
let empty = PostId ""
/// Convert a post ID to a string
let toString = function PostId pi -> pi
/// Create a new post ID
let create () = PostId (newId ())
/// An identifier for a web log
type WebLogId = WebLogId of string
/// Functions to support web log IDs
module WebLogId =
/// An empty web log ID
let empty = WebLogId ""
/// Convert a web log ID to a string
let toString = function WebLogId wli -> wli
/// Create a new web log ID
let create () = WebLogId (newId ())
/// A level of authorization for a given web log
type AuthorizationLevel =
/// <summary>The user may administer all aspects of a web log</summary>
| Administrator
/// <summary>The user is a known user of a web log</summary>
| User
/// An identifier for a web log user
type WebLogUserId = WebLogUserId of string
/// Functions to support web log user IDs
module WebLogUserId =
/// An empty web log user ID
let empty = WebLogUserId ""
/// Convert a web log user ID to a string
let toString = function WebLogUserId wli -> wli
/// Create a new web log user ID
let create () = WebLogUserId (newId ())

View File

@ -0,0 +1,63 @@
namespace MyWebLog.Features.Admin
open Microsoft.AspNetCore.Authorization
open Microsoft.AspNetCore.Mvc
open Microsoft.AspNetCore.Mvc.Rendering
open MyWebLog
open MyWebLog.Features.Shared
open RethinkDb.Driver.Net
open System.Threading.Tasks
/// Controller for admin-specific displays and routes
[<Route "/admin">]
[<Authorize>]
type AdminController () =
inherit MyWebLogController ()
[<HttpGet "">]
member this.Index () = task {
let getCount (f : WebLogId -> IConnection -> Task<int>) = f this.WebLog.id this.Db
let! posts = Data.Post.countByStatus Published |> getCount
let! drafts = Data.Post.countByStatus Draft |> getCount
let! pages = Data.Page.countAll |> getCount
let! pages = Data.Page.countAll |> getCount
let! listed = Data.Page.countListed |> getCount
let! cats = Data.Category.countAll |> getCount
let! topCats = Data.Category.countTopLevel |> getCount
return this.View (DashboardModel (
this.WebLog,
Posts = posts,
Drafts = drafts,
Pages = pages,
ListedPages = listed,
Categories = cats,
TopLevelCategories = topCats
))
}
[<HttpGet "settings">]
member this.Settings() = task {
let! allPages = Data.Page.findAll this.WebLog.id this.Db
return this.View (SettingsModel (
this.WebLog,
DefaultPages =
(Seq.singleton (SelectListItem ("- {Resources.FirstPageOfPosts} -", "posts"))
|> Seq.append (allPages |> Seq.map (fun p -> SelectListItem (p.title, PageId.toString p.id))))
))
}
[<HttpPost "settings">]
member this.SaveSettings (model : SettingsModel) = task {
match! Data.WebLog.findByHost this.WebLog.urlBase this.Db with
| Some webLog ->
let updated = model.UpdateSettings webLog
do! Data.WebLog.updateSettings updated this.Db
// Update cache
WebLogCache.set (WebLogCache.hostToDb this.HttpContext) updated
// TODO: confirmation message
return this.RedirectToAction (nameof this.Index);
| None -> return this.NotFound ()
}

View File

@ -0,0 +1,76 @@
namespace MyWebLog.Features.Admin
open MyWebLog
open MyWebLog.Features.Shared
/// The model used to display the dashboard
type DashboardModel (webLog) =
inherit MyWebLogModel (webLog)
/// The number of published posts
member val Posts = 0 with get, set
/// The number of post drafts
member val Drafts = 0 with get, set
/// The number of pages
member val Pages = 0 with get, set
/// The number of pages in the page list
member val ListedPages = 0 with get, set
/// The number of categories
member val Categories = 0 with get, set
/// The top-level categories
member val TopLevelCategories = 0 with get, set
open Microsoft.AspNetCore.Mvc.Rendering
open System.ComponentModel.DataAnnotations
/// View model for editing web log settings
type SettingsModel (webLog) =
inherit MyWebLogModel (webLog)
/// Default constructor
[<System.Obsolete "Only used for model binding; use the WebLogDetails constructor">]
new() = SettingsModel WebLog.empty
/// The name of the web log
[<Required (AllowEmptyStrings = false)>]
[<Display ( ResourceType = typeof<Resources>, Name = "Name")>]
member val Name = webLog.name with get, set
/// The subtitle of the web log
[<Display(ResourceType = typeof<Resources>, Name = "Subtitle")>]
member val Subtitle = (defaultArg webLog.subtitle "") with get, set
/// The default page
[<Required>]
[<Display(ResourceType = typeof<Resources>, Name = "DefaultPage")>]
member val DefaultPage = webLog.defaultPage with get, set
/// How many posts should appear on index pages
[<Required>]
[<Display(ResourceType = typeof<Resources>, Name = "PostsPerPage")>]
[<Range(0, 50)>]
member val PostsPerPage = webLog.postsPerPage with get, set
/// The time zone in which dates/times should be displayed
[<Required>]
[<Display(ResourceType = typeof<Resources>, Name = "TimeZone")>]
member val TimeZone = webLog.timeZone with get, set
/// Possible values for the default page
member val DefaultPages = Seq.empty<SelectListItem> with get, set
/// Update the settings object from the data in this form
member this.UpdateSettings (settings : WebLog) =
{ settings with
name = this.Name
subtitle = (match this.Subtitle with "" -> None | sub -> Some sub)
defaultPage = this.DefaultPage
postsPerPage = this.PostsPerPage
timeZone = this.TimeZone
}

View File

@ -0,0 +1,61 @@
@model DashboardModel
@{
Layout = "_AdminLayout";
ViewBag.Title = Resources.Dashboard;
}
<article class="container pt-3">
<div class="row">
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
<div class="card">
<header class="card-header text-white bg-primary">@Resources.Posts</header>
<div class="card-body">
<h6 class="card-subtitle text-muted pb-3">
@Resources.Published <span class="badge rounded-pill bg-secondary">@Model.Posts</span>
&nbsp; @Resources.Drafts <span class="badge rounded-pill bg-secondary">@Model.Drafts</span>
</h6>
<a asp-action="All" asp-controller="Post" class="btn btn-secondary me-2">@Resources.ViewAll</a>
<a asp-action="Edit" asp-controller="Post" asp-route-id="new" class="btn btn-primary">
@Resources.WriteANewPost
</a>
</div>
</div>
</section>
<section class="col-lg-5 col-xl-4 pb-3">
<div class="card">
<header class="card-header text-white bg-primary">@Resources.Pages</header>
<div class="card-body">
<h6 class="card-subtitle text-muted pb-3">
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Pages</span>
&nbsp; @Resources.ShownInPageList <span class="badge rounded-pill bg-secondary">@Model.ListedPages</span>
</h6>
<a asp-action="All" asp-controller="Page" class="btn btn-secondary me-2">@Resources.ViewAll</a>
<a asp-action="Edit" asp-controller="Page" asp-route-id="new" class="btn btn-primary">
@Resources.CreateANewPage
</a>
</div>
</div>
</section>
</div>
<div class="row">
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
<div class="card">
<header class="card-header text-white bg-secondary">@Resources.Categories</header>
<div class="card-body">
<h6 class="card-subtitle text-muted pb-3">
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Categories</span>
&nbsp; @Resources.TopLevel <span class="badge rounded-pill bg-secondary">@Model.TopLevelCategories</span>
</h6>
<a asp-action="All" asp-controller="Category" class="btn btn-secondary me-2">@Resources.ViewAll</a>
<a asp-action="Edit" asp-controller="Category" asp-route-id="new" class="btn btn-secondary">
@Resources.AddANewCategory
</a>
</div>
</div>
</section>
</div>
<div class="row pb-3">
<div class="col text-end">
<a asp-action="Settings" class="btn btn-secondary">@Resources.ModifySettings</a>
</div>
</div>
</article>

View File

@ -0,0 +1,15 @@
namespace MyWebLog.Features.Pages
open MyWebLog
open MyWebLog.Features.Shared
/// The model used to render a single page
type SinglePageModel (page : Page, webLog) =
inherit MyWebLogModel (webLog)
/// The page to be rendered
member _.Page with get () = page
/// Is this the home page?
member _.IsHome with get() = PageId.toString page.id = webLog.defaultPage

View File

@ -0,0 +1,65 @@
namespace MyWebLog.Features.Posts
open Microsoft.AspNetCore.Authorization
open Microsoft.AspNetCore.Mvc
open MyWebLog
open MyWebLog.Features.Pages
open MyWebLog.Features.Shared
open System
open System.Threading.Tasks
/// Handle post-related requests
[<Route "/post">]
[<Authorize>]
type PostController () =
inherit MyWebLogController ()
[<HttpGet "~/">]
[<AllowAnonymous>]
member this.Index () = task {
match this.WebLog.defaultPage with
| "posts" -> return! this.PageOfPosts 1
| pageId ->
match! Data.Page.findById (PageId pageId) this.WebLog.id this.Db with
| Some page ->
return this.ThemedView (defaultArg page.template "SinglePage", SinglePageModel (page, this.WebLog))
| None -> return this.NotFound ()
}
[<HttpGet "~/page/{pageNbr:int}">]
[<AllowAnonymous>]
member this.PageOfPosts (pageNbr : int) = task {
let! posts = Data.Post.findPageOfPublishedPosts this.WebLog.id pageNbr this.WebLog.postsPerPage this.Db
return this.ThemedView ("Index", MultiplePostModel (posts, this.WebLog))
}
[<HttpGet "~/{*link}">]
member this.CatchAll (link : string) = task {
let permalink = Permalink link
match! Data.Post.findByPermalink permalink this.WebLog.id this.Db with
| Some post -> return this.NotFound ()
// TODO: return via single-post action
| None ->
match! Data.Page.findByPermalink permalink this.WebLog.id this.Db with
| Some page ->
return this.ThemedView (defaultArg page.template "SinglePage", SinglePageModel (page, this.WebLog))
| None ->
// TOOD: search prior permalinks for posts and pages
// We tried, we really tried...
Console.Write($"Returning 404 for permalink |{permalink}|");
return this.NotFound ()
}
[<HttpGet "all">]
member this.All () = task {
do! Task.CompletedTask;
NotImplementedException () |> raise
}
[<HttpGet "{id}/edit">]
member this.Edit(postId : string) = task {
do! Task.CompletedTask;
NotImplementedException () |> raise
}

View File

@ -0,0 +1,11 @@
namespace MyWebLog.Features.Posts
open MyWebLog
open MyWebLog.Features.Shared
/// The model used to render multiple posts
type MultiplePostModel (posts : Post seq, webLog) =
inherit MyWebLogModel (webLog)
/// The posts to be rendered
member _.Posts with get () = posts

View File

@ -0,0 +1,45 @@
namespace MyWebLog.Features.Shared
open Microsoft.AspNetCore.Mvc
open Microsoft.Extensions.DependencyInjection
open MyWebLog
open RethinkDb.Driver.Net
open System.Security.Claims
/// Base class for myWebLog controllers
type MyWebLogController () =
inherit Controller ()
/// The data context to use to fulfil this request
member this.Db with get () = this.HttpContext.RequestServices.GetRequiredService<IConnection> ()
/// The details for the current web log
member this.WebLog with get () = WebLogCache.getByCtx this.HttpContext
/// The ID of the currently authenticated user
member this.UserId with get () =
this.User.Claims
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.NameIdentifier)
|> Option.map (fun c -> c.Value)
|> Option.defaultValue ""
/// Retern a themed view
member this.ThemedView (template : string, model : obj) : IActionResult =
// TODO: get actual version
this.ViewData["Version"] <- "2"
this.View (template, model)
/// Return a 404 response
member _.NotFound () : IActionResult =
base.NotFound ()
/// Redirect to an action in this controller
member _.RedirectToAction action : IActionResult =
base.RedirectToAction action
/// Base model class for myWebLog views
type MyWebLogModel (webLog : WebLog) =
/// The details for the web log
member _.WebLog with get () = webLog

View File

@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="WebLogCache.fs" />
<Compile Include="Features\Shared\SharedTypes.fs" />
<Compile Include="Features\Admin\AdminTypes.fs" />
<Compile Include="Features\Admin\AdminController.fs" />
<Compile Include="Features\Pages\PageTypes.fs" />
<Compile Include="Features\Posts\PostTypes.fs" />
<Compile Include="Features\Posts\PostController.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.fsproj" />
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Resources.Designer.fs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.fs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>

175
src/MyWebLog.FS/Program.fs Normal file
View File

@ -0,0 +1,175 @@
open Microsoft.AspNetCore.Mvc.Razor
open System.Reflection
/// Types to support feature folders
module FeatureSupport =
open Microsoft.AspNetCore.Mvc.ApplicationModels
open System.Collections.Concurrent
/// A controller model convention that identifies the feature in which a controller exists
type FeatureControllerModelConvention () =
/// A cache of controller types to features
static let _features = ConcurrentDictionary<string, string> ()
/// Derive the feature name from the controller's type
static let getFeatureName (typ : TypeInfo) : string option =
let cacheKey = Option.ofObj typ.FullName |> Option.defaultValue ""
match _features.ContainsKey cacheKey with
| true -> Some _features[cacheKey]
| false ->
let tokens = cacheKey.Split '.'
match tokens |> Array.contains "Features" with
| true ->
let feature = tokens |> Array.skipWhile (fun it -> it <> "Features") |> Array.skip 1 |> Array.tryHead
match feature with
| Some f ->
_features[cacheKey] <- f
feature
| None -> None
| false -> None
interface IControllerModelConvention with
/// <inheritdoc />
member _.Apply (controller: ControllerModel) =
controller.Properties.Add("feature", getFeatureName controller.ControllerType)
open Microsoft.AspNetCore.Mvc.Controllers
/// Expand the location token with the feature name
type FeatureViewLocationExpander () =
interface IViewLocationExpander with
/// <inheritdoc />
member _.ExpandViewLocations
(context : ViewLocationExpanderContext, viewLocations : string seq) : string seq =
if isNull context then nullArg (nameof context)
if isNull viewLocations then nullArg (nameof viewLocations)
match context.ActionContext.ActionDescriptor with
| :? ControllerActionDescriptor as descriptor ->
let feature = string descriptor.Properties["feature"]
viewLocations |> Seq.map (fun location -> location.Replace ("{2}", feature))
| _ -> invalidArg "context" "ActionDescriptor not found"
/// <inheritdoc />
member _.PopulateValues(_ : ViewLocationExpanderContext) = ()
open MyWebLog
/// Types to support themed views
module ThemeSupport =
/// Expand the location token with the theme path
type ThemeViewLocationExpander () =
interface IViewLocationExpander with
/// <inheritdoc />
member _.ExpandViewLocations
(context : ViewLocationExpanderContext, viewLocations : string seq) : string seq =
if isNull context then nullArg (nameof context)
if isNull viewLocations then nullArg (nameof viewLocations)
viewLocations |> Seq.map (fun location -> location.Replace ("{3}", string context.Values["theme"]))
/// <inheritdoc />
member _.PopulateValues (context : ViewLocationExpanderContext) =
if isNull context then nullArg (nameof context)
context.Values["theme"] <- (WebLogCache.getByCtx context.ActionContext.HttpContext).themePath
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
/// Custom middleware for this application
module Middleware =
open RethinkDb.Driver.Net
open System.Threading.Tasks
/// Middleware to derive the current web log
type WebLogMiddleware (next : RequestDelegate) =
member _.InvokeAsync (context : HttpContext) : Task = task {
let host = WebLogCache.hostToDb context
match WebLogCache.exists host with
| true -> ()
| false ->
let conn = context.RequestServices.GetRequiredService<IConnection> ()
match! Data.WebLog.findByHost (context.Request.Host.ToUriComponent ()) conn with
| Some details -> WebLogCache.set host details
| None -> ()
match WebLogCache.exists host with
| true -> do! next.Invoke context
| false -> context.Response.StatusCode <- 404
}
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Microsoft.AspNetCore.Mvc
open System
open System.IO
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
let _ =
builder.Services
.AddMvc(fun opts ->
opts.Conventions.Add (FeatureSupport.FeatureControllerModelConvention ())
opts.Filters.Add (AutoValidateAntiforgeryTokenAttribute ()))
.AddRazorOptions(fun opts ->
opts.ViewLocationFormats.Clear ()
opts.ViewLocationFormats.Add "/Themes/{3}/{0}.cshtml"
opts.ViewLocationFormats.Add "/Themes/{3}/Shared/{0}.cshtml"
opts.ViewLocationFormats.Add "/Themes/Default/{0}.cshtml"
opts.ViewLocationFormats.Add "/Themes/Default/Shared/{0}.cshtml"
opts.ViewLocationFormats.Add "/Features/{2}/{1}/{0}.cshtml"
opts.ViewLocationFormats.Add "/Features/{2}/{0}.cshtml"
opts.ViewLocationFormats.Add "/Features/Shared/{0}.cshtml"
opts.ViewLocationExpanders.Add (FeatureSupport.FeatureViewLocationExpander ())
opts.ViewLocationExpanders.Add (ThemeSupport.ThemeViewLocationExpander ()))
let _ =
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(fun opts ->
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 20.
opts.SlidingExpiration <- true
opts.AccessDeniedPath <- "/forbidden")
let _ = builder.Services.AddAuthorization()
let _ = builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor> ()
(* builder.Services.AddDbContext<WebLogDbContext>(o =>
{
// TODO: can get from DI?
var db = WebLogCache.HostToDb(new HttpContextAccessor().HttpContext!);
// "empty";
o.UseSqlite($"Data Source=Db/{db}.db");
}); *)
// Load themes
Directory.GetFiles (Directory.GetCurrentDirectory (), "MyWebLog.Themes.*.dll")
|> Array.map Assembly.LoadFile
|> ignore
let app = builder.Build ()
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
let _ = app.UseMiddleware<Middleware.WebLogMiddleware> ()
let _ = app.UseAuthentication ()
let _ = app.UseStaticFiles ()
let _ = app.UseRouting ()
let _ = app.UseAuthorization ()
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapControllers () |> ignore)
app.Run()
0 // Exit code

View File

@ -0,0 +1,28 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:29920",
"sslPort": 44344
}
},
"profiles": {
"MyWebLog.FS": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7134;http://localhost:5134",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,252 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Actions" xml:space="preserve">
<value>Actions</value>
</data>
<data name="AddANewCategory" xml:space="preserve">
<value>Add a New Category</value>
</data>
<data name="AddANewPage" xml:space="preserve">
<value>Add a New Page</value>
</data>
<data name="Admin" xml:space="preserve">
<value>Admin</value>
</data>
<data name="All" xml:space="preserve">
<value>All</value>
</data>
<data name="Categories" xml:space="preserve">
<value>Categories</value>
</data>
<data name="CreateANewPage" xml:space="preserve">
<value>Create a New Page</value>
</data>
<data name="Dashboard" xml:space="preserve">
<value>Dashboard</value>
</data>
<data name="DateFormatString" xml:space="preserve">
<value>MMMM d, yyyy</value>
</data>
<data name="DefaultPage" xml:space="preserve">
<value>Default Page</value>
</data>
<data name="Drafts" xml:space="preserve">
<value>Drafts</value>
</data>
<data name="Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="EditPage" xml:space="preserve">
<value>Edit Page</value>
</data>
<data name="EmailAddress" xml:space="preserve">
<value>E-mail Address</value>
</data>
<data name="FirstPageOfPosts" xml:space="preserve">
<value>First Page of Posts</value>
</data>
<data name="InListQuestion" xml:space="preserve">
<value>In List?</value>
</data>
<data name="LastUpdated" xml:space="preserve">
<value>Last Updated</value>
</data>
<data name="LogOff" xml:space="preserve">
<value>Log Off</value>
</data>
<data name="LogOn" xml:space="preserve">
<value>Log On</value>
</data>
<data name="LogOnTo" xml:space="preserve">
<value>Log On to</value>
</data>
<data name="ModifySettings" xml:space="preserve">
<value>Modify Settings</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="No" xml:space="preserve">
<value>No</value>
</data>
<data name="Pages" xml:space="preserve">
<value>Pages</value>
</data>
<data name="PageText" xml:space="preserve">
<value>Page Text</value>
</data>
<data name="Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Permalink" xml:space="preserve">
<value>Permalink</value>
</data>
<data name="Posts" xml:space="preserve">
<value>Posts</value>
</data>
<data name="PostsPerPage" xml:space="preserve">
<value>Posts per Page</value>
</data>
<data name="Published" xml:space="preserve">
<value>Published</value>
</data>
<data name="SaveChanges" xml:space="preserve">
<value>Save Changes</value>
</data>
<data name="ShowInPageList" xml:space="preserve">
<value>Show in Page List</value>
</data>
<data name="ShownInPageList" xml:space="preserve">
<value>Shown in Page List</value>
</data>
<data name="Subtitle" xml:space="preserve">
<value>Subtitle</value>
</data>
<data name="ThereAreXCategories" xml:space="preserve">
<value>There are {0} categories</value>
</data>
<data name="ThereAreXPages" xml:space="preserve">
<value>There are {0} pages</value>
</data>
<data name="ThereAreXPublishedPostsAndYDrafts" xml:space="preserve">
<value>There are {0} published posts and {1} drafts</value>
</data>
<data name="TimeZone" xml:space="preserve">
<value>Time Zone</value>
</data>
<data name="Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="TopLevel" xml:space="preserve">
<value>Top Level</value>
</data>
<data name="ViewAll" xml:space="preserve">
<value>View All</value>
</data>
<data name="WebLogSettings" xml:space="preserve">
<value>Web Log Settings</value>
</data>
<data name="WriteANewPost" xml:space="preserve">
<value>Write a New Post</value>
</data>
<data name="Yes" xml:space="preserve">
<value>Yes</value>
</data>
</root>

View File

@ -0,0 +1,27 @@
/// <summary>
/// In-memory cache of web log details
/// </summary>
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
/// settings update page</remarks>
module MyWebLog.WebLogCache
open Microsoft.AspNetCore.Http
open System.Collections.Concurrent
/// The cache of web log details
let private _cache = ConcurrentDictionary<string, WebLog> ()
/// Transform a hostname to a database name
let hostToDb (ctx : HttpContext) = ctx.Request.Host.ToUriComponent().Replace (':', '_')
/// Does a host exist in the cache?
let exists host = _cache.ContainsKey host
/// Get the details for a web log via its host
let getByHost host = _cache[host]
/// Get the details for a web log via its host
let getByCtx ctx = _cache[hostToDb ctx]
/// Set the details for a particular host
let set host details = _cache[host] <- details

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<Content Remove="Themes\BitBadger\solutions.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Themes\BitBadger\solutions.json" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyWebLog\MyWebLog.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@namespace MyWebLog.Themes
@using MyWebLog.Features.Shared
@using MyWebLog.Properties
@addTagHelper *, MyWebLog

View File

@ -3,9 +3,17 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.1.32210.238 VisualStudioVersion = 17.1.32210.238
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.csproj", "{0177C744-F913-4352-A0EC-478B4B0388C3}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog", "MyWebLog\MyWebLog.csproj", "{3139DA09-C999-465A-BC98-02FEC3BD7E88}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyWebLog", "MyWebLog\MyWebLog.csproj", "{3139DA09-C999-465A-BC98-02FEC3BD7E88}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Themes.BitBadger", "MyWebLog.Themes.BitBadger\MyWebLog.Themes.BitBadger.csproj", "{729F7AB3-2300-4390-B972-71D32FBBBF50}"
EndProject
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.FS", "MyWebLog.FS\MyWebLog.FS.fsproj", "{4D62F235-73BA-42A6-8AA1-29D0D046E115}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.DataCS", "MyWebLog.DataCS\MyWebLog.DataCS.csproj", "{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.fsproj", "{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -13,14 +21,30 @@ Global
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.Build.0 = Release|Any CPU
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.Build.0 = Debug|Any CPU {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.ActiveCfg = Release|Any CPU {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.Build.0 = Release|Any CPU {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.Build.0 = Release|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{729F7AB3-2300-4390-B972-71D32FBBBF50}.Release|Any CPU.Build.0 = Release|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D62F235-73BA-42A6-8AA1-29D0D046E115}.Release|Any CPU.Build.0 = Release|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.Build.0 = Release|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Release|Any CPU.Build.0 = Release|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

0
src/MyWebLog.v2/Data.fs Normal file
View File

490
src/MyWebLog.v2/Domain.fs Normal file
View File

@ -0,0 +1,490 @@
namespace MyWebLog.Domain
// -- Supporting Types --
/// Types of markup text supported
type MarkupText =
/// Text in Markdown format
| Markdown of string
/// Text in HTML format
| Html of string
/// Functions to support maniuplating markup text
module MarkupText =
/// Get the string representation of this markup text
let toString it =
match it with
| Markdown x -> "Markdown", x
| Html x -> "HTML", x
||> sprintf "%s: %s"
/// Get the HTML value of the text
let toHtml = function
| Markdown it -> sprintf "TODO: convert to HTML - %s" it
| Html it -> it
/// Parse a string representation to markup text
let ofString (it : string) =
match true with
| _ when it.StartsWith "Markdown: " -> it.Substring 10 |> Markdown
| _ when it.StartsWith "HTML: " -> it.Substring 6 |> Html
| _ -> sprintf "Cannot determine text type - %s" it |> invalidOp
/// Authorization levels
type AuthorizationLevel =
/// Authorization to administer a weblog
| Administrator
/// Authorization to comment on a weblog
| User
/// Functions to support authorization levels
module AuthorizationLevel =
/// Get the string reprsentation of an authorization level
let toString = function Administrator -> "Administrator" | User -> "User"
/// Create an authorization level from a string
let ofString it =
match it with
| "Administrator" -> Administrator
| "User" -> User
| _ -> sprintf "%s is not an authorization level" it |> invalidOp
/// Post statuses
type PostStatus =
/// Post has not been released for public consumption
| Draft
/// Post is released
| Published
/// Functions to support post statuses
module PostStatus =
/// Get the string representation of a post status
let toString = function Draft -> "Draft" | Published -> "Published"
/// Create a post status from a string
let ofString it =
match it with
| "Draft" -> Draft
| "Published" -> Published
| _ -> sprintf "%s is not a post status" it |> invalidOp
/// Comment statuses
type CommentStatus =
/// Comment is approved
| Approved
/// Comment has yet to be approved
| Pending
/// Comment was flagged as spam
| Spam
/// Functions to support comment statuses
module CommentStatus =
/// Get the string representation of a comment status
let toString = function Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam"
/// Create a comment status from a string
let ofString it =
match it with
| "Approved" -> Approved
| "Pending" -> Pending
| "Spam" -> Spam
| _ -> sprintf "%s is not a comment status" it |> invalidOp
/// Seconds since the Unix epoch
type UnixSeconds = UnixSeconds of int64
/// Functions to support Unix seconds
module UnixSeconds =
/// Get the long (int64) representation of Unix seconds
let toLong = function UnixSeconds it -> it
/// Zero seconds past the epoch
let none = UnixSeconds 0L
// -- IDs --
open System
// See https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID for info on "short GUIDs"
/// A short GUID
type ShortGuid = ShortGuid of Guid
/// Functions to support short GUIDs
module ShortGuid =
/// Encode a GUID into a short GUID
let toString = function
| ShortGuid guid ->
Convert.ToBase64String(guid.ToByteArray ())
.Replace("/", "_")
.Replace("+", "-")
.Substring (0, 22)
/// Decode a short GUID into a GUID
let ofString (it : string) =
it.Replace("_", "/").Replace ("-", "+")
|> (sprintf "%s==" >> Convert.FromBase64String >> Guid >> ShortGuid)
/// Create a new short GUID
let create () = (Guid.NewGuid >> ShortGuid) ()
/// The empty short GUID
let empty = ShortGuid Guid.Empty
/// The ID of a category
type CategoryId = CategoryId of ShortGuid
/// Functions to support category IDs
module CategoryId =
/// Get the string representation of a page ID
let toString = function CategoryId it -> ShortGuid.toString it
/// Create a category ID from its string representation
let ofString = ShortGuid.ofString >> CategoryId
/// An empty category ID
let empty = CategoryId ShortGuid.empty
/// The ID of a comment
type CommentId = CommentId of ShortGuid
/// Functions to support comment IDs
module CommentId =
/// Get the string representation of a comment ID
let toString = function CommentId it -> ShortGuid.toString it
/// Create a comment ID from its string representation
let ofString = ShortGuid.ofString >> CommentId
/// An empty comment ID
let empty = CommentId ShortGuid.empty
/// The ID of a page
type PageId = PageId of ShortGuid
/// Functions to support page IDs
module PageId =
/// Get the string representation of a page ID
let toString = function PageId it -> ShortGuid.toString it
/// Create a page ID from its string representation
let ofString = ShortGuid.ofString >> PageId
/// An empty page ID
let empty = PageId ShortGuid.empty
/// The ID of a post
type PostId = PostId of ShortGuid
/// Functions to support post IDs
module PostId =
/// Get the string representation of a post ID
let toString = function PostId it -> ShortGuid.toString it
/// Create a post ID from its string representation
let ofString = ShortGuid.ofString >> PostId
/// An empty post ID
let empty = PostId ShortGuid.empty
/// The ID of a user
type UserId = UserId of ShortGuid
/// Functions to support user IDs
module UserId =
/// Get the string representation of a user ID
let toString = function UserId it -> ShortGuid.toString it
/// Create a user ID from its string representation
let ofString = ShortGuid.ofString >> UserId
/// An empty user ID
let empty = UserId ShortGuid.empty
/// The ID of a web log
type WebLogId = WebLogId of ShortGuid
/// Functions to support web log IDs
module WebLogId =
/// Get the string representation of a web log ID
let toString = function WebLogId it -> ShortGuid.toString it
/// Create a web log ID from its string representation
let ofString = ShortGuid.ofString >> WebLogId
/// An empty web log ID
let empty = WebLogId ShortGuid.empty
// -- Domain Entities --
// fsharplint:disable RecordFieldNames
/// A revision of a post or page
type Revision = {
/// The instant which this revision was saved
asOf : UnixSeconds
/// The text
text : MarkupText
}
with
/// An empty revision
static member empty =
{ asOf = UnixSeconds.none
text = Markdown ""
}
/// A page with static content
[<CLIMutable; NoComparison; NoEquality>]
type Page = {
/// The Id
id : PageId
/// The Id of the web log to which this page belongs
webLogId : WebLogId
/// The Id of the author of this page
authorId : UserId
/// The title of the page
title : string
/// The link at which this page is displayed
permalink : string
/// The instant this page was published
publishedOn : UnixSeconds
/// The instant this page was last updated
updatedOn : UnixSeconds
/// Whether this page shows as part of the web log's navigation
showInPageList : bool
/// The current text of the page
text : MarkupText
/// Revisions of this page
revisions : Revision list
}
with
static member empty =
{ id = PageId.empty
webLogId = WebLogId.empty
authorId = UserId.empty
title = ""
permalink = ""
publishedOn = UnixSeconds.none
updatedOn = UnixSeconds.none
showInPageList = false
text = Markdown ""
revisions = []
}
/// An entry in the list of pages displayed as part of the web log (derived via query)
type PageListEntry = {
/// The permanent link for the page
permalink : string
/// The title of the page
title : string
}
/// A web log
[<CLIMutable; NoComparison; NoEquality>]
type WebLog = {
/// The Id
id : WebLogId
/// The name
name : string
/// The subtitle
subtitle : string option
/// The default page ("posts" or a page Id)
defaultPage : string
/// The path of the theme (within /views/themes)
themePath : string
/// The URL base
urlBase : string
/// The time zone in which dates/times should be displayed
timeZone : string
/// A list of pages to be rendered as part of the site navigation (not stored)
pageList : PageListEntry list
}
with
/// An empty web log
static member empty =
{ id = WebLogId.empty
name = ""
subtitle = None
defaultPage = ""
themePath = "default"
urlBase = ""
timeZone = "America/New_York"
pageList = []
}
/// An authorization between a user and a web log
type Authorization = {
/// The Id of the web log to which this authorization grants access
webLogId : WebLogId
/// The level of access granted by this authorization
level : AuthorizationLevel
}
/// A user of myWebLog
[<CLIMutable; NoComparison; NoEquality>]
type User = {
/// The Id
id : UserId
/// The user name (e-mail address)
userName : string
/// The first name
firstName : string
/// The last name
lastName : string
/// The user's preferred name
preferredName : string
/// The hash of the user's password
passwordHash : string
/// The URL of the user's personal site
url : string option
/// The user's authorizations
authorizations : Authorization list
}
with
/// An empty user
static member empty =
{ id = UserId.empty
userName = ""
firstName = ""
lastName = ""
preferredName = ""
passwordHash = ""
url = None
authorizations = []
}
/// Functions supporting users
module User =
/// Claims for this user
let claims user =
user.authorizations
|> List.map (fun a -> sprintf "%s|%s" (WebLogId.toString a.webLogId) (AuthorizationLevel.toString a.level))
/// A category to which posts may be assigned
[<CLIMutable; NoComparison; NoEquality>]
type Category = {
/// The Id
id : CategoryId
/// The Id of the web log to which this category belongs
webLogId : WebLogId
/// The displayed name
name : string
/// The slug (used in category URLs)
slug : string
/// A longer description of the category
description : string option
/// The parent Id of this category (if a subcategory)
parentId : CategoryId option
/// The categories for which this category is the parent
children : CategoryId list
}
with
/// An empty category
static member empty =
{ id = CategoryId.empty
webLogId = WebLogId.empty
name = ""
slug = ""
description = None
parentId = None
children = []
}
/// A comment (applies to a post)
[<CLIMutable; NoComparison; NoEquality>]
type Comment = {
/// The Id
id : CommentId
/// The Id of the post to which this comment applies
postId : PostId
/// The Id of the comment to which this comment is a reply
inReplyToId : CommentId option
/// The name of the commentor
name : string
/// The e-mail address of the commentor
email : string
/// The URL of the commentor's personal website
url : string option
/// The status of the comment
status : CommentStatus
/// The instant the comment was posted
postedOn : UnixSeconds
/// The text of the comment
text : string
}
with
static member empty =
{ id = CommentId.empty
postId = PostId.empty
inReplyToId = None
name = ""
email = ""
url = None
status = Pending
postedOn = UnixSeconds.none
text = ""
}
/// A post
[<CLIMutable; NoComparison; NoEquality>]
type Post = {
/// The Id
id : PostId
/// The Id of the web log to which this post belongs
webLogId : WebLogId
/// The Id of the author of this post
authorId : UserId
/// The status
status : PostStatus
/// The title
title : string
/// The link at which the post resides
permalink : string
/// The instant on which the post was originally published
publishedOn : UnixSeconds
/// The instant on which the post was last updated
updatedOn : UnixSeconds
/// The text of the post
text : MarkupText
/// The Ids of the categories to which this is assigned
categoryIds : CategoryId list
/// The tags for the post
tags : string list
/// The permalinks at which this post may have once resided
priorPermalinks : string list
/// Revisions of this post
revisions : Revision list
/// The categories to which this is assigned (not stored in database)
categories : Category list
/// The comments (not stored in database)
comments : Comment list
}
with
static member empty =
{ id = PostId.empty
webLogId = WebLogId.empty
authorId = UserId.empty
status = Draft
title = ""
permalink = ""
publishedOn = UnixSeconds.none
updatedOn = UnixSeconds.none
text = Markdown ""
categoryIds = []
tags = []
priorPermalinks = []
revisions = []
categories = []
comments = []
}
// --- UI Support ---
/// Counts of items displayed on the admin dashboard
type DashboardCounts = {
/// The number of pages for the web log
pages : int
/// The number of pages for the web log
posts : int
/// The number of categories for the web log
categories : int
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Strings.fs" />
<Compile Include="Domain.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<Content Remove="Resources/en-US.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources/en-US.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DotLiquid" Version="2.2.548" />
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
<PackageReference Include="Suave" Version="2.6.1" />
<PackageReference Include="Suave.DotLiquid" Version="2.6.1" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,4 @@
open MyWebLog
open Suave
startWebServer defaultConfig (Successful.OK (Strings.get "LastUpdated"))

View File

@ -0,0 +1,83 @@
{
"Action": "Action",
"Added": "Added",
"AddNew": "Add New",
"AddNewCategory": "Add New Category",
"AddNewPage": "Add New Page",
"AddNewPost": "Add New Post",
"Admin": "Admin",
"AndPublished": " and Published",
"andXMore": "and {0} more...",
"at": "at",
"BackToCategoryList": "Back to Category List",
"BackToPageList": "Back to Page List",
"BackToPostList": "Back to Post List",
"Categories": "Categories",
"Category": "Category",
"CategoryDeleteWarning": "Are you sure you wish to delete the category",
"Close": "Close",
"Comments": "Comments",
"Dashboard": "Dashboard",
"Date": "Date",
"Delete": "Delete",
"Description": "Description",
"Edit": "Edit",
"EditCategory": "Edit Category",
"EditPage": "Edit Page",
"EditPost": "Edit Post",
"EmailAddress": "E-mail Address",
"ErrBadAppConfig": "Could not convert config.json to myWebLog configuration",
"ErrBadLogOnAttempt": "Invalid e-mail address or password",
"ErrDataConfig": "Could not convert data-config.json to RethinkDB connection",
"ErrNotConfigured": "is not properly configured for myWebLog",
"Error": "Error",
"LastUpdated": "Last Updated",
"LastUpdatedDate": "Last Updated Date",
"ListAll": "List All",
"LoadedIn": "Loaded in",
"LogOff": "Log Off",
"LogOn": "Log On",
"MsgCategoryDeleted": "Deleted category {0} successfully",
"MsgCategoryEditSuccess": "{0} category successfully",
"MsgLogOffSuccess": "Log off successful | Have a nice day!",
"MsgLogOnSuccess": "Log on successful | Welcome to myWebLog!",
"MsgPageDeleted": "Deleted page successfully",
"MsgPageEditSuccess": "{0} page successfully",
"MsgPostEditSuccess": "{0}{1} post successfully",
"Name": "Name",
"NewerPosts": "Newer Posts",
"NextPost": "Next Post",
"NoComments": "No Comments",
"NoParent": "No Parent",
"OlderPosts": "Older Posts",
"OneComment": "1 Comment",
"PageDeleteWarning": "Are you sure you wish to delete the page",
"PageDetails": "Page Details",
"PageHash": "Page #",
"Pages": "Pages",
"ParentCategory": "Parent Category",
"Password": "Password",
"Permalink": "Permalink",
"PermanentLinkTo": "Permanent Link to",
"PostDetails": "Post Details",
"Posts": "Posts",
"PostsTagged": "Posts Tagged",
"PostStatus": "Post Status",
"PoweredBy": "Powered by",
"PreviousPost": "Previous Post",
"PublishedDate": "Published Date",
"PublishThisPost": "Publish This Post",
"Save": "Save",
"Seconds": "Seconds",
"ShowInPageList": "Show in Page List",
"Slug": "Slug",
"startingWith": "starting with",
"Status": "Status",
"Tags": "Tags",
"Time": "Time",
"Title": "Title",
"Updated": "Updated",
"View": "View",
"Warning": "Warning",
"XComments": "{0} Comments"
}

View File

@ -0,0 +1,40 @@
module MyWebLog.Strings
open System.Collections.Generic
open System.Globalization
open System.IO
open System.Reflection
open System.Text.Json
/// The locales we'll try to load
let private supportedLocales = [ "en-US" ]
/// The fallback locale, if a key is not found in a non-default locale
let private fallbackLocale = "en-US"
/// Get an embedded JSON file as a string
let private getEmbedded locale =
let str = sprintf "MyWebLog.Resources.%s.json" locale |> Assembly.GetExecutingAssembly().GetManifestResourceStream
use rdr = new StreamReader (str)
rdr.ReadToEnd()
/// The dictionary of localized strings
let private strings =
supportedLocales
|> List.map (fun loc -> loc, getEmbedded loc |> JsonSerializer.Deserialize<Dictionary<string, string>>)
|> dict
/// Get a key from the resources file for the given locale
let getForLocale locale key =
let getString thisLocale =
match strings.ContainsKey thisLocale && strings.[thisLocale].ContainsKey key with
| true -> Some strings.[thisLocale].[key]
| false -> None
match getString locale with
| Some xlat -> Some xlat
| None when locale <> fallbackLocale -> getString fallbackLocale
| None -> None
|> function Some xlat -> xlat | None -> sprintf "%s.%s" locale key
/// Translate the key for the current locale
let get key = getForLocale CultureInfo.CurrentCulture.Name key

View File

@ -31,7 +31,7 @@ public class ImageTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.ImageTagHelper
return; return;
} }
output.Attributes.SetAttribute("src", $"~/img/{Theme}/{context.AllAttributes["src"]?.Value}"); output.Attributes.SetAttribute("src", $"~/{Theme}/img/{context.AllAttributes["src"]?.Value}");
ProcessUrlAttribute("src", output); ProcessUrlAttribute("src", output);
} }
} }

View File

@ -43,11 +43,11 @@ public class LinkTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.LinkTagHelper
switch (context.AllAttributes["rel"]?.Value.ToString()) switch (context.AllAttributes["rel"]?.Value.ToString())
{ {
case "stylesheet": case "stylesheet":
output.Attributes.SetAttribute("href", $"~/css/{Theme}/{Style}.css"); output.Attributes.SetAttribute("href", $"~/{Theme}/css/{Style}.css");
break; break;
case "icon": case "icon":
output.Attributes.SetAttribute("type", "image/x-icon"); output.Attributes.SetAttribute("type", "image/x-icon");
output.Attributes.SetAttribute("href", $"~/img/{Theme}/favicon.ico"); output.Attributes.SetAttribute("href", $"~/{Theme}/img/favicon.ico");
break; break;
} }
ProcessUrlAttribute("href", output); ProcessUrlAttribute("href", output);

View File

@ -6,14 +6,6 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Content Remove="Themes\BitBadger\solutions.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Themes\BitBadger\solutions.json" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="wwwroot\img\" /> <Folder Include="wwwroot\img\" />
</ItemGroup> </ItemGroup>
@ -27,7 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.csproj" /> <ProjectReference Include="..\MyWebLog.DataCS\MyWebLog.DataCS.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using MyWebLog; using MyWebLog;
using MyWebLog.Features; using MyWebLog.Features;
using MyWebLog.Features.Users; using MyWebLog.Features.Users;
using System.Reflection;
if (args.Length > 0 && args[0] == "init") if (args.Length > 0 && args[0] == "init")
{ {
@ -47,6 +48,10 @@ builder.Services.AddDbContext<WebLogDbContext>(o =>
o.UseSqlite($"Data Source=Db/{db}.db"); o.UseSqlite($"Data Source=Db/{db}.db");
}); });
// Load themes
Array.ForEach(Directory.GetFiles(Directory.GetCurrentDirectory(), "MyWebLog.Themes.*.dll"),
it => { Assembly.LoadFile(it); });
var app = builder.Build(); var app = builder.Build();
app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict }); app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict });