diff --git a/src/MyWebLog/DotLiquidBespoke.fs b/src/MyWebLog/DotLiquidBespoke.fs index 3b29deb..cb43451 100644 --- a/src/MyWebLog/DotLiquidBespoke.fs +++ b/src/MyWebLog/DotLiquidBespoke.fs @@ -3,6 +3,7 @@ module MyWebLog.DotLiquidBespoke open System open System.IO +open System.Web open DotLiquid open MyWebLog.ViewModels @@ -22,11 +23,13 @@ let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> st | Some link -> linkFunc (webLog ctx) (Permalink link) | None -> $"alert('unknown item type {item.GetType().Name}')" + /// A filter to generate an absolute link type AbsoluteLinkFilter () = static member AbsoluteLink (ctx : Context, item : obj) = permalink ctx item WebLog.absoluteUrl + /// A filter to generate a link with posts categorized under the given category type CategoryLinkFilter () = static member CategoryLink (ctx : Context, catObj : obj) = @@ -50,6 +53,7 @@ type EditPageLinkFilter () = |> function | Some pageId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/page/{pageId}/edit") | None -> $"alert('unknown page object type {pageObj.GetType().Name}')" + /// A filter to generate a link that will edit a post type EditPostLinkFilter () = @@ -62,6 +66,7 @@ type EditPostLinkFilter () = |> function | Some postId -> WebLog.relativeUrl (webLog ctx) (Permalink $"admin/post/{postId}/edit") | None -> $"alert('unknown post object type {postObj.GetType().Name}')" + /// A filter to generate nav links, highlighting the active link (exact match) type NavLinkFilter () = @@ -78,11 +83,62 @@ type NavLinkFilter () = } |> Seq.fold (+) "" + +/// Create various items in the page header based on the state of the page being generated +type PageHeadTag () = + inherit Tag () + + override this.Render (context : Context, result : TextWriter) = + let webLog = webLog context + // spacer + let s = " " + let getBool name = + context.Environments[0].[name] |> Option.ofObj |> Option.map Convert.ToBoolean |> Option.defaultValue false + + result.WriteLine $"""""" + + // Theme assets + let has fileName = File.Exists (Path.Combine ("wwwroot", "themes", webLog.themePath, fileName)) + if has "style.css" then + result.WriteLine $"""{s}""" + if has "favicon.ico" then + result.WriteLine $"""{s}""" + + // RSS feeds and canonical URLs + let feedLink title url = + let escTitle = HttpUtility.HtmlAttributeEncode title + let relUrl = WebLog.relativeUrl webLog (Permalink url) + $"""{s}""" + + if webLog.rss.feedEnabled && getBool "is_home" then + result.WriteLine (feedLink webLog.name webLog.rss.feedName) + result.WriteLine $"""{s}""" + + if webLog.rss.categoryEnabled && getBool "is_category_home" then + let slug = context.Environments[0].["slug"] :?> string + result.WriteLine (feedLink webLog.name $"category/{slug}/{webLog.rss.feedName}") + + if webLog.rss.tagEnabled && getBool "is_tag_home" then + let slug = context.Environments[0].["slug"] :?> string + result.WriteLine (feedLink webLog.name $"tag/{slug}/{webLog.rss.feedName}") + + if getBool "is_post" then + let post = context.Environments[0].["model"] :?> PostDisplay + let url = WebLog.absoluteUrl webLog (Permalink post.posts[0].permalink) + result.WriteLine $"""{s}""" + + if getBool "is_page" then + let page = context.Environments[0].["page"] :?> DisplayPage + let url = WebLog.absoluteUrl webLog (Permalink page.permalink) + result.WriteLine $"""{s}""" + + /// A filter to generate a relative link type RelativeLinkFilter () = static member RelativeLink (ctx : Context, item : obj) = permalink ctx item WebLog.relativeUrl + /// A filter to generate a link with posts tagged with the given tag type TagLinkFilter () = static member TagLink (ctx : Context, tag : string) = @@ -92,6 +148,7 @@ type TagLinkFilter () = | Some tagMap -> tagMap.urlValue | None -> tag.Replace (" ", "+") |> function tagUrl -> WebLog.relativeUrl (webLog ctx) (Permalink $"tag/{tagUrl}/") + /// Create links for a user to log on or off, and a dashboard link if they are logged off type UserLinksTag () = diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 68ab950..cfc5b35 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -33,28 +33,16 @@ let deriveFeedType (ctx : HttpContext) feedPath : (FeedType * int) option = debug (fun () -> "Found standard feed") Some (StandardFeed feedPath, postCount) | false -> - // Category feed - match CategoryCache.get ctx |> Array.tryFind (fun cat -> cat.slug = feedPath.Replace (name, "")) with - | Some cat -> - debug (fun () -> "Found category feed") - Some (CategoryFeed (CategoryId cat.id, feedPath), postCount) + // Category and tag feeds are handled by defined routes; check for custom feed + match webLog.rss.customFeeds + |> List.tryFind (fun it -> feedPath.EndsWith (Permalink.toString it.path)) with + | Some feed -> + debug (fun () -> "Found custom feed") + Some (Custom (feed, feedPath), + feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) | None -> - // Tag feed - match feedPath.StartsWith "/tag/" with - | true -> - debug (fun () -> "Found tag feed") - Some (TagFeed (feedPath.Replace("/tag/", "").Replace(name, ""), feedPath), postCount) - | false -> - // Custom feed - match webLog.rss.customFeeds - |> List.tryFind (fun it -> feedPath.EndsWith (Permalink.toString it.path)) with - | Some feed -> - debug (fun () -> "Found custom feed") - Some (Custom (feed, feedPath), - feed.podcast |> Option.map (fun p -> p.itemsInFeed) |> Option.defaultValue postCount) - | None -> - debug (fun () -> $"No matching feed found") - None + debug (fun () -> $"No matching feed found") + None /// Determine the function to retrieve posts for the given feed let private getFeedPosts (webLog : WebLog) feedType = @@ -252,12 +240,41 @@ let private selfAndLink webLog feedType = |> function | path -> Permalink path, Permalink (path.Replace ($"/{webLog.rss.feedName}", "")) +/// Set the title and description of the feed based on its source +let private setTitleAndDescription feedType (webLog : WebLog) (cats : DisplayCategory[]) (feed : SyndicationFeed) = + let cleanText opt def = TextSyndicationContent (stripHtml (defaultArg opt def)) + match feedType with + | StandardFeed _ -> + feed.Title <- cleanText None webLog.name + feed.Description <- cleanText webLog.subtitle webLog.name + | CategoryFeed (CategoryId catId, _) -> + let cat = cats |> Array.find (fun it -> it.id = catId) + feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category""" + feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """ + | TagFeed (tag, _) -> + feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag""" + feed.Description <- cleanText None $"""Posts with the "{tag}" tag""" + | Custom (custom, _) -> + match custom.podcast with + | Some podcast -> + feed.Title <- cleanText None podcast.title + feed.Description <- cleanText podcast.subtitle podcast.title + | None -> + match custom.source with + | Category (CategoryId catId) -> + let cat = cats |> Array.find (fun it -> it.id = catId) + feed.Title <- cleanText None $"""{webLog.name} - "{stripHtml cat.name}" Category""" + feed.Description <- cleanText cat.description $"""Posts categorized under "{cat.name}" """ + | Tag tag -> + feed.Title <- cleanText None $"""{webLog.name} - "{tag}" Tag""" + feed.Description <- cleanText None $"""Posts with the "{tag}" tag""" + /// Create a feed with a known non-zero-length list of posts let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backgroundTask { let webLog = ctx.WebLog let conn = ctx.Conn - let! authors = Post.getAuthors webLog posts conn - let! tagMaps = Post.getTagMappings webLog posts conn + let! authors = getAuthors webLog posts conn + let! tagMaps = getTagMappings webLog posts conn let cats = CategoryCache.get ctx let podcast = match feedType with Custom (feed, _) when Option.isSome feed.podcast -> Some feed | _ -> None let self, link = selfAndLink webLog feedType @@ -274,14 +291,13 @@ let createFeed (feedType : FeedType) posts : HttpHandler = fun next ctx -> backg let feed = SyndicationFeed () addNamespace feed "content" Namespace.content + setTitleAndDescription feedType webLog cats feed - feed.Title <- TextSyndicationContent webLog.name - feed.Description <- TextSyndicationContent <| defaultArg webLog.subtitle webLog.name feed.LastUpdatedTime <- DateTimeOffset <| (List.head posts).updatedOn feed.Generator <- generator ctx feed.Items <- posts |> Seq.ofList |> Seq.map toItem feed.Language <- "en" - feed.Id <- webLog.urlBase + feed.Id <- WebLog.absoluteUrl webLog link webLog.rss.copyright |> Option.iter (fun copy -> feed.Copyright <- TextSyndicationContent copy) feed.Links.Add (SyndicationLink (Uri (WebLog.absoluteUrl webLog self), "self", "", "application/rss+xml", 0L)) diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index b9a428d..98d6984 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -160,6 +160,32 @@ let templatesForTheme (ctx : HttpContext) (typ : string) = } |> Array.ofSeq +/// Get all authors for a list of posts as metadata items +let getAuthors (webLog : WebLog) (posts : Post list) conn = + posts + |> List.map (fun p -> p.authorId) + |> List.distinct + |> Data.WebLogUser.findNames webLog.id conn + +/// Get all tag mappings for a list of posts as metadata items +let getTagMappings (webLog : WebLog) (posts : Post list) = + posts + |> List.map (fun p -> p.tags) + |> List.concat + |> List.distinct + |> fun tags -> Data.TagMap.findMappingForTags tags webLog.id + +/// Get all category IDs for the given slug (includes owned subcategories) +let getCategoryIds slug ctx = + let allCats = CategoryCache.get ctx + let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + // Category pages include posts in subcategories + allCats + |> Seq.ofArray + |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) + |> Seq.map (fun c -> CategoryId c.id) + |> List.ofSeq + open Microsoft.Extensions.Logging /// Log level for debugging diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index ca502e1..10e99ee 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -2,10 +2,21 @@ module MyWebLog.Handlers.Post open System +open MyWebLog /// Parse a slug and page number from an "everything else" URL -let private parseSlugAndPage (slugAndPage : string seq) = - let slugs = (slugAndPage |> Seq.skip 1 |> Seq.head).Split "/" |> Array.filter (fun it -> it <> "") +let private parseSlugAndPage webLog (slugAndPage : string seq) = + let fullPath = slugAndPage |> Seq.head + let slugPath = slugAndPage |> Seq.skip 1 |> Seq.head + let slugs, isFeed = + let feedName = $"/{webLog.rss.feedName}" + let notBlank = Array.filter (fun it -> it <> "") + if ( (webLog.rss.categoryEnabled && fullPath.StartsWith "/category/") + || (webLog.rss.tagEnabled && fullPath.StartsWith "/tag/" )) + && slugPath.EndsWith feedName then + notBlank (slugPath.Replace(feedName, "").Split "/"), true + else + notBlank (slugPath.Split "/"), false let pageIdx = Array.IndexOf (slugs, "page") let pageNbr = match pageIdx with @@ -13,7 +24,7 @@ let private parseSlugAndPage (slugAndPage : string seq) = | idx when idx + 2 = slugs.Length -> Some (int slugs[pageIdx + 1]) | _ -> None let slugParts = if pageIdx > 0 then Array.truncate pageIdx slugs else slugs - pageNbr, String.Join ("/", slugParts) + pageNbr, String.Join ("/", slugParts), isFeed /// The type of post list being prepared type ListType = @@ -23,23 +34,6 @@ type ListType = | SinglePost | TagList -open MyWebLog - -/// Get all authors for a list of posts as metadata items -let getAuthors (webLog : WebLog) (posts : Post list) conn = - posts - |> List.map (fun p -> p.authorId) - |> List.distinct - |> Data.WebLogUser.findNames webLog.id conn - -/// Get all tag mappings for a list of posts as metadata items -let getTagMappings (webLog : WebLog) (posts : Post list) = - posts - |> List.map (fun p -> p.tags) - |> List.concat - |> List.distinct - |> fun tags -> Data.TagMap.findMappingForTags tags webLog.id - open System.Threading.Tasks open DotLiquid open MyWebLog.ViewModels @@ -91,7 +85,12 @@ let preparePostList webLog posts listType (url : string) pageNbr perPage ctx con olderLink = olderLink olderName = olderPost |> Option.map (fun p -> p.title) } - return Hash.FromAnonymousObject {| model = model; categories = CategoryCache.get ctx; tag_mappings = tagMappings |} + return Hash.FromAnonymousObject {| + model = model + categories = CategoryCache.get ctx + tag_mappings = tagMappings + is_post = match listType with SinglePost -> true | _ -> false + |} } open Giraffe @@ -117,27 +116,30 @@ let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task { let webLog = ctx.WebLog let conn = ctx.Conn - match parseSlugAndPage slugAndPage with - | Some pageNbr, slug -> + match parseSlugAndPage webLog slugAndPage with + | Some pageNbr, slug, isFeed -> let allCats = CategoryCache.get ctx let cat = allCats |> Array.find (fun cat -> cat.slug = slug) - // Category pages include posts in subcategories - let catIds = - allCats - |> Seq.ofArray - |> Seq.filter (fun c -> c.id = cat.id || Array.contains cat.name c.parentNames) - |> Seq.map (fun c -> CategoryId c.id) - |> List.ofSeq - match! Data.Post.findPageOfCategorizedPosts webLog.id catIds pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") - hash.Add ("subtitle", cat.description.Value) - hash.Add ("is_category", true) - return! themedView "index" next ctx hash - | _ -> return! Error.notFound next ctx - | None, _ -> return! Error.notFound next ctx + if isFeed then + return! Feed.generate (Feed.CategoryFeed ((CategoryId cat.id), $"category/{slug}/{webLog.rss.feedName}")) + (defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx + else + let allCats = CategoryCache.get ctx + let cat = allCats |> Array.find (fun cat -> cat.slug = slug) + // Category pages include posts in subcategories + match! Data.Post.findPageOfCategorizedPosts webLog.id (getCategoryIds slug ctx) pageNbr webLog.postsPerPage + conn with + | posts when List.length posts > 0 -> + let! hash = preparePostList webLog posts CategoryList cat.slug pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"{cat.name}: Category Archive{pgTitle}") + hash.Add ("subtitle", defaultArg cat.description "") + hash.Add ("is_category", true) + hash.Add ("is_category_home", (pageNbr = 1)) + hash.Add ("slug", slug) + return! themedView "index" next ctx hash + | _ -> return! Error.notFound next ctx + | None, _, _ -> return! Error.notFound next ctx } open System.Web @@ -147,33 +149,39 @@ open System.Web let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task { let webLog = ctx.WebLog let conn = ctx.Conn - match parseSlugAndPage slugAndPage with - | Some pageNbr, rawTag -> + match parseSlugAndPage webLog slugAndPage with + | Some pageNbr, rawTag, isFeed -> let urlTag = HttpUtility.UrlDecode rawTag let! tag = backgroundTask { match! Data.TagMap.findByUrlValue urlTag webLog.id conn with | Some m -> return m.tag | None -> return urlTag } - match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with - | posts when List.length posts > 0 -> - let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn - let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" - hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") - hash.Add ("is_tag", true) - return! themedView "index" next ctx hash - // Other systems use hyphens for spaces; redirect if this is an old tag link - | _ -> - let spacedTag = tag.Replace ("-", " ") - match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + if isFeed then + return! Feed.generate (Feed.TagFeed (tag, $"tag/{rawTag}/{webLog.rss.feedName}")) + (defaultArg webLog.rss.itemsInFeed webLog.postsPerPage) next ctx + else + match! Data.Post.findPageOfTaggedPosts webLog.id tag pageNbr webLog.postsPerPage conn with | posts when List.length posts > 0 -> - let endUrl = if pageNbr = 1 then "" else $"page/{pageNbr}" - return! - redirectTo true - (WebLog.relativeUrl webLog (Permalink $"""tag/{spacedTag.Replace (" ", "+")}/{endUrl}""")) - next ctx - | _ -> return! Error.notFound next ctx - | None, _ -> return! Error.notFound next ctx + let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.postsPerPage ctx conn + let pgTitle = if pageNbr = 1 then "" else $""" (Page {pageNbr})""" + hash.Add ("page_title", $"Posts Tagged “{tag}”{pgTitle}") + hash.Add ("is_tag", true) + hash.Add ("is_tag_home", (pageNbr = 1)) + hash.Add ("slug", rawTag) + return! themedView "index" next ctx hash + // Other systems use hyphens for spaces; redirect if this is an old tag link + | _ -> + let spacedTag = tag.Replace ("-", " ") + match! Data.Post.findPageOfTaggedPosts webLog.id spacedTag pageNbr 1 conn with + | posts when List.length posts > 0 -> + let endUrl = if pageNbr = 1 then "" else $"page/{pageNbr}" + return! + redirectTo true + (WebLog.relativeUrl webLog (Permalink $"""tag/{spacedTag.Replace (" ", "+")}/{endUrl}""")) + next ctx + | _ -> return! Error.notFound next ctx + | None, _, _ -> return! Error.notFound next ctx } // GET / diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 42f3be8..589c4c4 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -39,7 +39,11 @@ module CatchAll = | Some page -> debug (fun () -> $"Found page by permalink") yield fun next ctx -> - Hash.FromAnonymousObject {| page = DisplayPage.fromPage webLog page; page_title = page.title |} + Hash.FromAnonymousObject {| + page = DisplayPage.fromPage webLog page + page_title = page.title + is_page = true + |} |> themedView (defaultArg page.template "single-page") next ctx | None -> () // RSS feed diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index ab6d573..09c7773 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -12,8 +12,8 @@ - + diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 189931e..645c9b0 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -215,6 +215,7 @@ let main args = ] |> List.iter Template.RegisterFilter + Template.RegisterTag "page_head" Template.RegisterTag "user_links" [ // Domain types diff --git a/src/MyWebLog/themes/bit-badger/layout.liquid b/src/MyWebLog/themes/bit-badger/layout.liquid index d75e4fc..5cf46a6 100644 --- a/src/MyWebLog/themes/bit-badger/layout.liquid +++ b/src/MyWebLog/themes/bit-badger/layout.liquid @@ -3,11 +3,9 @@ - {{ page_title }} » Bit Badger Solutions - - + {% page_head -%}