Move OpenGraph property generation to models (#52)

- Add auto-OpenGraph field to web log
- Only generate properties for posts/pages without them if this flag is set
- Set flag to yes on v3 database migration
- Add JSON converter for OpenGraph type
- Add tests for models
This commit is contained in:
2025-07-10 23:03:16 -04:00
parent 210dd41cee
commit 3ad6b5a521
17 changed files with 799 additions and 293 deletions
+5 -1
View File
@@ -348,6 +348,9 @@ type WebLog = {
/// <summary>Redirect rules for this weblog</summary>
RedirectRules: RedirectRule list
/// <summary>Whether to automatically apply OpenGraph properties to all pages / posts</summary>
AutoOpenGraph: bool
} with
/// <summary>An empty web log</summary>
@@ -364,7 +367,8 @@ type WebLog = {
Rss = RssOptions.Empty
AutoHtmx = false
Uploads = Database
RedirectRules = [] }
RedirectRules = []
AutoOpenGraph = true }
/// <summary>
/// Any extra path where this web log is hosted (blank if web log is hosted at the root of the domain)
+93 -21
View File
@@ -19,6 +19,17 @@ module private Helpers =
/// <summary>Pipeline with most extensions enabled</summary>
let markdownPipeline = MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().UseColorCode().Build()
/// <summary>Derive a MIME type from the given URL and candidates</summary>
/// <param name="url">The URL from which the MIME type should be derived</param>
/// <param name="candidates">The candidates for the MIME type derivation</param>
/// <returns><c>Some</c> with the type if it was derived, <c>None</c> otherwise</returns>
let deriveMimeType (url: string) (candidates: System.Collections.Generic.IDictionary<string, string>) =
match url.LastIndexOf '.' with
| extIdx when extIdx >= 0 ->
let ext = url[extIdx + 1..]
if candidates.ContainsKey ext then Some candidates[ext] else None
| _ -> None
/// <summary>Functions to support NodaTime manipulation</summary>
module Noda =
@@ -401,6 +412,15 @@ type OpenGraphAudio = {
SecureUrl = None
Type = None }
/// <summary>MIME types we can derive from the file extension</summary>
static member private DeriveTypes =
[ "aac", "audio/aac"
"mp3", "audio/mpeg"
"oga", "audio/ogg"
"wav", "audio/wav"
"weba", "audio/webm" ]
|> dict
/// <summary>The <c>meta</c> properties for this image</summary>
member this.Properties = seq {
yield ("og:audio", this.Url)
@@ -411,8 +431,9 @@ type OpenGraphAudio = {
match this.Type with
| Some typ -> yield ("og:audio:type", typ)
| None ->
// TODO: derive mime type from extension
()
match deriveMimeType this.Url OpenGraphAudio.DeriveTypes with
| Some it -> yield "og:audio:type", it
| None -> ()
}
@@ -447,21 +468,36 @@ type OpenGraphImage = {
Height = None
Alt = None }
/// <summary>MIME types we can derive from the file extension</summary>
static member private DeriveTypes =
[ "bmp", "image/bmp"
"gif", "image/gif"
"ico", "image/vnd.microsoft.icon"
"jpeg", "image/jpeg"
"jpg", "image/jpeg"
"png", "image/png"
"svg", "image/svg+xml"
"tif", "image/tiff"
"tiff", "image/tiff"
"webp", "image/webp" ]
|> dict
/// <summary>The <c>meta</c> properties for this image</summary>
member this.Properties = seq {
yield ("og:image", this.Url)
yield "og:image", this.Url
match this.SecureUrl with
| Some url -> yield ("og:image:secure_url", url)
| None when this.Url.StartsWith "https:" -> yield ("og:image:secure_url", this.Url)
| Some url -> yield "og:image:secure_url", url
| None when this.Url.StartsWith "https:" -> yield "og:image:secure_url", this.Url
| None -> ()
match this.Type with
| Some typ -> yield ("og:image:type", typ)
| Some typ -> yield "og:image:type", typ
| None ->
// TODO: derive mime type based on common image extensions
()
match this.Width with Some width -> yield ("og:image:width", string width) | None -> ()
match this.Height with Some height -> yield ("og:image:height", string height) | None -> ()
match this.Alt with Some alt -> yield ("og:image:alt", alt) | None -> ()
match deriveMimeType this.Url OpenGraphImage.DeriveTypes with
| Some it -> yield "og:image:type", it
| None -> ()
match this.Width with Some width -> yield "og:image:width", string width | None -> ()
match this.Height with Some height -> yield "og:image:height", string height | None -> ()
match this.Alt with Some alt -> yield "og:image:alt", alt | None -> ()
}
@@ -492,20 +528,30 @@ type OpenGraphVideo = {
Width = None
Height = None }
/// <summary>MIME types we can derive from the file extension</summary>
static member private DeriveTypes =
[ "avi", "video/x-msvideo"
"mp4", "video/mp4"
"mpeg", "video/mpeg"
"ogv", "video/ogg"
"webm", "video/webm" ]
|> dict
/// <summary>The <c>meta</c> properties for this video</summary>
member this.Properties = seq {
yield ("og:video", this.Url)
yield "og:video", this.Url
match this.SecureUrl with
| Some url -> yield ("og:video:secure_url", url)
| None when this.Url.StartsWith "https:" -> yield ("og:video:secure_url", this.Url)
| Some url -> yield "og:video:secure_url", url
| None when this.Url.StartsWith "https:" -> yield "og:video:secure_url", this.Url
| None -> ()
match this.Type with
| Some typ -> yield ("og:video:type", typ)
| Some typ -> yield "og:video:type", typ
| None ->
// TODO: derive mime type based on common video extensions
()
match this.Width with Some width -> yield ("og:video:width", string width) | None -> ()
match this.Height with Some height -> yield ("og:video:height", string height) | None -> ()
match deriveMimeType this.Url OpenGraphVideo.DeriveTypes with
| Some it -> yield "og:video:type", it
| None -> ()
match this.Width with Some width -> yield "og:video:width", string width | None -> ()
match this.Height with Some height -> yield "og:video:height", string height | None -> ()
}
@@ -567,7 +613,6 @@ type OpenGraphType =
/// <summary>Properties for OpenGraph</summary>
[<CLIMutable>]
type OpenGraphProperties = {
/// <summary>The type of object represented</summary>
Type: OpenGraphType
@@ -594,7 +639,34 @@ type OpenGraphProperties = {
/// <summary>Free-form items</summary>
Other: MetaItem list option
}
} with
/// <summary>An empty set of OpenGraph properties</summary>
static member Empty =
{ Type = Article
Image = OpenGraphImage.Empty
Audio = None
Description = None
Determiner = None
Locale = None
LocaleAlternate = None
Video = None
Other = None }
/// <summary>The <c>meta</c> properties for this page or post</summary>
member this.Properties = seq {
yield "og:type", string this.Type
yield! this.Image.Properties
match this.Description with Some desc -> yield "og:description", desc | None -> ()
match this.Determiner with Some det -> yield "og:determiner", det | None -> ()
match this.Locale with Some loc -> yield "og:locale", loc | None -> ()
match this.LocaleAlternate with
| Some alt -> yield! alt |> List.map (fun it -> "og:locale:alternate", it)
| None -> ()
match this.Audio with Some audio -> yield! audio.Properties | None -> ()
match this.Video with Some video -> yield! video.Properties | None -> ()
match this.Other with Some oth -> yield! oth |> List.map (fun it -> it.Name, it.Value) | None -> ()
}
/// <summary>A permanent link</summary>
+23 -18
View File
@@ -1249,36 +1249,41 @@ type SettingsModel = {
/// <summary>The default location for uploads</summary>
Uploads: string
/// <summary>Whether to automatically apply OpenGraph properties to all pages and posts</summary>
AutoOpenGraph: bool
} with
/// <summary>Create a settings model from a web log</summary>
/// <param name="webLog">The web log from which this model should be created</param>
/// <returns>A populated <c>SettingsModel</c> instance</returns>
static member FromWebLog(webLog: WebLog) =
{ Name = webLog.Name
Slug = webLog.Slug
Subtitle = defaultArg webLog.Subtitle ""
DefaultPage = webLog.DefaultPage
PostsPerPage = webLog.PostsPerPage
TimeZone = webLog.TimeZone
ThemeId = string webLog.ThemeId
AutoHtmx = webLog.AutoHtmx
Uploads = string webLog.Uploads }
{ Name = webLog.Name
Slug = webLog.Slug
Subtitle = defaultArg webLog.Subtitle ""
DefaultPage = webLog.DefaultPage
PostsPerPage = webLog.PostsPerPage
TimeZone = webLog.TimeZone
ThemeId = string webLog.ThemeId
AutoHtmx = webLog.AutoHtmx
Uploads = string webLog.Uploads
AutoOpenGraph = webLog.AutoOpenGraph }
/// <summary>Update a web log with settings from the form</summary>
/// <param name="webLog">The web log to be updated</param>
/// <returns>The web log, updated with the value from this model</returns>
member this.Update(webLog: WebLog) =
{ webLog with
Name = this.Name
Slug = this.Slug
Subtitle = if this.Subtitle = "" then None else Some this.Subtitle
DefaultPage = this.DefaultPage
PostsPerPage = this.PostsPerPage
TimeZone = this.TimeZone
ThemeId = ThemeId this.ThemeId
AutoHtmx = this.AutoHtmx
Uploads = UploadDestination.Parse this.Uploads }
Name = this.Name
Slug = this.Slug
Subtitle = if this.Subtitle = "" then None else Some this.Subtitle
DefaultPage = this.DefaultPage
PostsPerPage = this.PostsPerPage
TimeZone = this.TimeZone
ThemeId = ThemeId this.ThemeId
AutoHtmx = this.AutoHtmx
Uploads = UploadDestination.Parse this.Uploads
AutoOpenGraph = this.AutoOpenGraph }
/// <summary>View model for uploading a file</summary>