From e33966b3dfc9d2b5a7ba0e520b37270937d7bb66 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 15 Jul 2025 23:38:02 -0400 Subject: [PATCH] WIP on OpenGraph post/page model (#52) - Removed SecureUrl prop; will generate if URL starts with https: --- src/MyWebLog.Domain/SupportTypes.fs | 53 ++----- src/MyWebLog.Domain/ViewModels.fs | 138 +++++++++++++++++- .../Domain/SupportTypesTests.fs | 54 +++---- 3 files changed, 173 insertions(+), 72 deletions(-) diff --git a/src/MyWebLog.Domain/SupportTypes.fs b/src/MyWebLog.Domain/SupportTypes.fs index 9fe14d2..f271343 100644 --- a/src/MyWebLog.Domain/SupportTypes.fs +++ b/src/MyWebLog.Domain/SupportTypes.fs @@ -399,18 +399,14 @@ type OpenGraphAudio = { /// The URL for this audio file Url: string - /// The URL for this audio file for sites that require https - SecureUrl: string option - /// The MIME type of the audio file Type: string option } with /// An empty audio file static member Empty = - { Url = "" - SecureUrl = None - Type = None } + { Url = "" + Type = None } /// MIME types we can derive from the file extension static member private DeriveTypes = @@ -423,13 +419,10 @@ type OpenGraphAudio = { /// The meta properties for this image member this.Properties = seq { - yield ("og:audio", this.Url) - match this.SecureUrl with - | Some url -> yield ("og:audio:secure_url", url) - | None when this.Url.StartsWith "https:" -> yield ("og:audio:secure_url", this.Url) - | None -> () + yield "og:audio", this.Url + if this.Url.StartsWith "https:" then yield "og:audio:secure_url", this.Url match this.Type with - | Some typ -> yield ("og:audio:type", typ) + | Some typ -> yield "og:audio:type", typ | None -> match deriveMimeType this.Url OpenGraphAudio.DeriveTypes with | Some it -> yield "og:audio:type", it @@ -443,9 +436,6 @@ type OpenGraphImage = { /// The URL for this image Url: string - /// The URL for this image for sites that require https - SecureUrl: string option - /// The MIME type of the image Type: string option @@ -461,12 +451,11 @@ type OpenGraphImage = { /// An empty image file static member Empty = - { Url = "" - SecureUrl = None - Type = None - Width = None - Height = None - Alt = None } + { Url = "" + Type = None + Width = None + Height = None + Alt = None } /// MIME types we can derive from the file extension static member private DeriveTypes = @@ -485,10 +474,7 @@ type OpenGraphImage = { /// The meta properties for this image member this.Properties = seq { 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 - | None -> () + if this.Url.StartsWith "https:" then yield "og:image:secure_url", this.Url match this.Type with | Some typ -> yield "og:image:type", typ | None -> @@ -507,9 +493,6 @@ type OpenGraphVideo = { /// The URL for this video Url: string - /// The URL for this video for sites that require https - SecureUrl: string option - /// The MIME type of the video Type: string option @@ -522,11 +505,10 @@ type OpenGraphVideo = { /// An empty video file static member Empty = - { Url = "" - SecureUrl = None - Type = None - Width = None - Height = None } + { Url = "" + Type = None + Width = None + Height = None } /// MIME types we can derive from the file extension static member private DeriveTypes = @@ -540,10 +522,7 @@ type OpenGraphVideo = { /// The meta properties for this video member this.Properties = seq { 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 - | None -> () + if this.Url.StartsWith "https:" then yield "og:video:secure_url", this.Url match this.Type with | Some typ -> yield "og:video:type", typ | None -> diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 776f801..20f631e 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -372,6 +372,63 @@ type EditCommonModel() = /// The text of the page or post member val Text = "" with get, set + /// Whether to assign OpenGraph properties to this page or post + member val AssignOpenGraph = false with get, set + + /// The type of object represented by this page or post + member val OpenGraphType = "" with get, set + + /// The URL for the image associated with this page or post + member val OpenGraphImageUrl = "" with get, set + + /// The MIME type of the image associated with this page or post + member val OpenGraphImageType = "" with get, set + + /// The width of the image associated with this page or post + member val OpenGraphImageWidth = "" with get, set + + /// The height of the image associated with this page or post + member val OpenGraphImageHeight = "" with get, set + + /// The alternate text for the image associated with this page or post + member val OpenGraphImageAlt = "" with get, set + + /// The URL of an audio file associated with this page or post + member val OpenGraphAudioUrl = "" with get, set + + /// The MIME type of the audio file associated with this page or post + member val OpenGraphAudioType = "" with get, set + + /// A short description of this page or post + member val OpenGraphDescription = "" with get, set + + /// A word excluded from the title when alphabetizing + member val OpenGraphDeterminer = "" with get, set + + /// The primary locale for this page or post + member val OpenGraphLocale = "" with get, set + + /// Alternate locales in which this page or post is available + member val OpenGraphAlternateLocales = "" with get, set + + /// The URL of a video file associated with this page or post + member val OpenGraphVideoUrl = "" with get, set + + /// The MIME type of a video file associated with this page or post + member val OpenGraphVideoType = "" with get, set + + /// The width of the video file associated with this page or post + member val OpenGraphVideoWidth = "" with get, set + + /// The height of the video file associated with this page or post + member val OpenGraphVideoHeight = "" with get, set + + /// The names of extra OpenGraph properties for this page or post + member val OpenGraphExtraNames: string array = [||] with get, set + + /// The values of extra OpenGraph properties for this page or post + member val OpenGraphExtraValues: string array = [||] with get, set + /// Names of metadata items member val MetaNames: string array = [||] with get, set @@ -381,9 +438,40 @@ type EditCommonModel() = /// Whether this is a new page or post member this.IsNew with get () = this.Id = "new" + /// Populate the OpenGraph properties + /// The existing OpenGraph property set + member private this.PopulateOpenGraph(og: OpenGraphProperties) = + this.AssignOpenGraph <- true + this.OpenGraphImageUrl <- og.Image.Url + this.OpenGraphImageType <- defaultArg og.Image.Type "" + this.OpenGraphImageWidth <- defaultArg (og.Image.Width |> Option.map string) "" + this.OpenGraphImageHeight <- defaultArg (og.Image.Height |> Option.map string) "" + this.OpenGraphImageAlt <- defaultArg og.Image.Alt "" + this.OpenGraphDescription <- defaultArg og.Description "" + this.OpenGraphDeterminer <- defaultArg og.Determiner "" + this.OpenGraphLocale <- defaultArg og.Locale "" + this.OpenGraphAlternateLocales <- defaultArg (og.LocaleAlternate |> Option.map (String.concat ", ")) "" + match og.Audio with + | Some audio -> + this.OpenGraphAudioUrl <- audio.Url + this.OpenGraphAudioType <- defaultArg audio.Type "" + | None -> () + match og.Video with + | Some video -> + this.OpenGraphVideoUrl <- video.Url + this.OpenGraphVideoType <- defaultArg video.Type "" + this.OpenGraphVideoWidth <- defaultArg (video.Width |> Option.map string) "" + this.OpenGraphVideoHeight <- defaultArg (video.Height |> Option.map string) "" + | None -> () + match og.Other with + | Some other -> + this.OpenGraphExtraNames <- other |> List.map _.Name |> Array.ofList + this.OpenGraphExtraValues <- other |> List.map _.Value |> Array.ofList + | None -> () + /// Fill the properties of this object from a page /// The page from which this model should be populated - member this.PopulateFromPage (page: Page) = + member this.PopulateFromPage(page: Page) = let latest = findLatestRevision page.Revisions let metaItems = if page.Metadata.Length = 0 then [ MetaItem.Empty ] else page.Metadata this.Id <- string page.Id @@ -395,10 +483,11 @@ type EditCommonModel() = this.Text <- latest.Text.Text this.MetaNames <- metaItems |> List.map _.Name |> Array.ofList this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList + page.OpenGraph |> Option.iter this.PopulateOpenGraph /// Fill the properties of this object from a post /// The post from which this model should be populated - member this.PopulateFromPost (post: Post) = + member this.PopulateFromPost(post: Post) = let latest = findLatestRevision post.Revisions let metaItems = if post.Metadata.Length = 0 then [ MetaItem.Empty ] else post.Metadata this.Id <- string post.Id @@ -411,7 +500,52 @@ type EditCommonModel() = this.Text <- latest.Text.Text this.MetaNames <- metaItems |> List.map _.Name |> Array.ofList this.MetaValues <- metaItems |> List.map _.Value |> Array.ofList + post.OpenGraph |> Option.iter this.PopulateOpenGraph + /// Convert the properties of the model into a set of OpenGraph properties + /// The current web log + member this.ToOpenGraph(webLog: WebLog) = + if this.AssignOpenGraph then + let toAbsolute (url: string) = + if url.StartsWith "http" then url else webLog.AbsoluteUrl (Permalink url) + let audio = + match this.OpenGraphAudioUrl.Trim() with + | "" -> None + | url -> Some { OpenGraphAudio.Url = toAbsolute url; Type = noneIfBlank this.OpenGraphAudioType } + let video = + match this.OpenGraphVideoUrl.Trim() with + | "" -> None + | url -> + Some { + OpenGraphVideo.Url = toAbsolute url + Type = noneIfBlank this.OpenGraphVideoType + Width = noneIfBlank this.OpenGraphVideoWidth |> Option.map int + Height = noneIfBlank this.OpenGraphVideoHeight |> Option.map int + } + Some { + Type = if this.OpenGraphType = "" then Article else OpenGraphType.Parse this.OpenGraphType + Image = { + Url = toAbsolute this.OpenGraphImageUrl + Type = noneIfBlank this.OpenGraphImageType + Width = noneIfBlank this.OpenGraphImageWidth |> Option.map int + Height = noneIfBlank this.OpenGraphImageHeight |> Option.map int + Alt = noneIfBlank this.OpenGraphImageAlt + } + Description = noneIfBlank this.OpenGraphDescription + Determiner = noneIfBlank this.OpenGraphDeterminer + Locale = noneIfBlank this.OpenGraphLocale + LocaleAlternate = noneIfBlank this.OpenGraphAlternateLocales + |> Option.map (fun alts -> alts.Split ',' |> Array.map _.Trim() |> List.ofArray) + Audio = audio + Video = video + Other = Seq.zip this.OpenGraphExtraNames this.OpenGraphExtraValues + |> Seq.filter (fun it -> fst it > "") + |> Seq.map (fun it -> { Name = fst it; Value = snd it }) + |> List.ofSeq + |> function it -> match it with [] -> None | _ -> Some it + } + else + None /// View model to edit a custom RSS feed [] diff --git a/src/MyWebLog.Tests/Domain/SupportTypesTests.fs b/src/MyWebLog.Tests/Domain/SupportTypesTests.fs index e215441..884a471 100644 --- a/src/MyWebLog.Tests/Domain/SupportTypesTests.fs +++ b/src/MyWebLog.Tests/Domain/SupportTypesTests.fs @@ -273,16 +273,10 @@ let openGraphAudioTests = testList "OpenGraphAudio" [ props[1] ("og:audio:secure_url", "https://test.this") "The Secure URL was not written correctly" } test "succeeds with all properties filled" { - let props = - { Url = "http://test.this" - SecureUrl = Some "https://test.other" - Type = Some "audio/mpeg" }.Properties - |> Array.ofSeq - Expect.hasLength props 3 "There should be three properties" + let props = Array.ofSeq { Url = "http://test.this"; Type = Some "audio/mpeg" }.Properties + Expect.hasLength props 2 "There should be two properties" Expect.equal props[0] ("og:audio", "http://test.this") "The URL was not written correctly" - Expect.equal - props[1] ("og:audio:secure_url", "https://test.other") "The Secure URL was not written correctly" - Expect.equal props[2] ("og:audio:type", "audio/mpeg") "The MIME type was not written correctly" + Expect.equal props[1] ("og:audio:type", "audio/mpeg") "The MIME type was not written correctly" } test "succeeds when deriving AAC" { let props = Array.ofSeq { OpenGraphAudio.Empty with Url = "/this/cool.file.aac" }.Properties @@ -333,21 +327,18 @@ let openGraphImageTests = testList "OpenGraphImage" [ } test "succeeds with all properties filled" { let props = - { Url = "http://test.this" - SecureUrl = Some "https://test.other" - Type = Some "image/jpeg" - Width = Some 400 - Height = Some 600 - Alt = Some "This ought to be good" }.Properties + { Url = "http://test.this" + Type = Some "image/jpeg" + Width = Some 400 + Height = Some 600 + Alt = Some "This ought to be good" }.Properties |> Array.ofSeq - Expect.hasLength props 6 "There should be six properties" + Expect.hasLength props 5 "There should be five properties" Expect.equal props[0] ("og:image", "http://test.this") "The URL was not written correctly" - Expect.equal - props[1] ("og:image:secure_url", "https://test.other") "The Secure URL was not written correctly" - Expect.equal props[2] ("og:image:type", "image/jpeg") "The MIME type was not written correctly" - Expect.equal props[3] ("og:image:width", "400") "The width was not written correctly" - Expect.equal props[4] ("og:image:height", "600") "The height was not written correctly" - Expect.equal props[5] ("og:image:alt", "This ought to be good") "The alt text was not written correctly" + Expect.equal props[1] ("og:image:type", "image/jpeg") "The MIME type was not written correctly" + Expect.equal props[2] ("og:image:width", "400") "The width was not written correctly" + Expect.equal props[3] ("og:image:height", "600") "The height was not written correctly" + Expect.equal props[4] ("og:image:alt", "This ought to be good") "The alt text was not written correctly" } test "succeeds when deriving BMP" { let props = Array.ofSeq { OpenGraphImage.Empty with Url = "/old/windows.bmp" }.Properties @@ -419,19 +410,16 @@ let openGraphVideoTests = testList "OpenGraphVideo" [ } test "succeeds with all properties filled" { let props = - { Url = "http://test.this" - SecureUrl = Some "https://test.other" - Type = Some "video/mpeg" - Width = Some 1200 - Height = Some 900 }.Properties + { Url = "http://test.this" + Type = Some "video/mpeg" + Width = Some 1200 + Height = Some 900 }.Properties |> Array.ofSeq - Expect.hasLength props 5 "There should be five properties" + Expect.hasLength props 4 "There should be five properties" Expect.equal props[0] ("og:video", "http://test.this") "The URL was not written correctly" - Expect.equal - props[1] ("og:video:secure_url", "https://test.other") "The Secure URL was not written correctly" - Expect.equal props[2] ("og:video:type", "video/mpeg") "The MIME type was not written correctly" - Expect.equal props[3] ("og:video:width", "1200") "The width was not written correctly" - Expect.equal props[4] ("og:video:height", "900") "The height was not written correctly" + Expect.equal props[1] ("og:video:type", "video/mpeg") "The MIME type was not written correctly" + Expect.equal props[2] ("og:video:width", "1200") "The width was not written correctly" + Expect.equal props[3] ("og:video:height", "900") "The height was not written correctly" } test "succeeds when deriving AVI" { let props = Array.ofSeq { OpenGraphVideo.Empty with Url = "/my.video.avi" }.Properties