packages feed

kitchen-sink-0.1.0.0: src/KitchenSink/Layout/Blog/Fragments.hs

{-# LANGUAGE OverloadedRecordDot #-}

module KitchenSink.Layout.Blog.Fragments where

import Control.Monad (when)
import Data.Aeson (Value, object, (.=))
import Data.Aeson.Text (encodeToLazyText)
import Data.Either (fromRight)
import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes, fromMaybe, isJust)
import Data.Text qualified as Text
import Data.Text.Lazy qualified as LText
import Data.Time.Clock (UTCTime (..))
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.Format.ISO8601 (iso8601Show)
import Lucid as Lucid
import Lucid.Base qualified as Lucid
import Prelude ((&&), (+), (||), maxBound)

import Text.Atom.Feed qualified as Atom

import KitchenSink.Core.Assembler (runAssembler)
import KitchenSink.Core.Assembler qualified as CoreAssembler
import KitchenSink.Core.Build.Site ()
import KitchenSink.Core.Build.Target (DestinationLocation, OutputPrefix, Url, destination, destinationUrl)
import KitchenSink.Core.Build.Target qualified as Core
import KitchenSink.Core.Section hiding (Section)
import KitchenSink.Prelude

import KitchenSink.Core.Assembler.Sections
import KitchenSink.Layout.Blog.Analyses
import KitchenSink.Layout.Blog.ArticleTypes
import KitchenSink.Layout.Blog.Destinations
import KitchenSink.Layout.Blog.Extensions (Article, Assembler, Section, Target)
import KitchenSink.Layout.Blog.Metadata

assembleHeader :: UrlPrefix -> OutputPrefix -> TopicStats -> DestinationLocation -> Article [Text] -> Assembler (Lucid.Html ())
assembleHeader urlPrefix prefix stats currentDestination art =
    r
        <$> (extract <$> json @() @PreambleData art isPreamble)
        <*> (fmap extract <$> jsonm @() @SocialData art isSocial)
        <*> (fmap extract <$> jsonm @() @TopicData art isTopic)
  where
    r :: PreambleData -> Maybe SocialData -> Maybe TopicData -> Lucid.Html ()
    r content social topic = do
        let author_ = case social >>= twitter of
                Nothing -> span_ (toHtml . author $ content)
                Just handle -> a_ [href_ $ "https://twitter.com/" <> handle] (mconcat ["@", toHtml handle])

        let topiclist_ =
                case topic of
                    Nothing -> mempty
                    Just d ->
                        do
                            div_ [class_ "topiclist"]
                            $ mconcat
                                [ topicTag urlPrefix prefix stats currentDestination t | t <- topics d
                                ]

        let wc =
                toHtml
                    $ show
                    $ fromRight 0
                    $ contentWordCount
                    <$> runAssembler (getSections art isMainContent)
        let infos = analyzeArticle art
        let lc = toHtml . show . length $ infos.linkInfos
        let ic = toHtml . show . length $ infos.imageInfos
        let bc = toHtml . show . length $ infos.snippetInfos
        let headersentence pr =
                case preambleDateText pr of
                    Just txt ->
                        mconcat ["On ", span_ (toHtml txt), ", by ", author_, ", ", wc, " words, ", bc, " code snippets, ", lc, " links, ", ic, "images."]
                    Nothing ->
                        mconcat ["By ", author_, ", ", wc, " words, ", bc, " code snippets, ", lc, " links, ", ic, "images."]

        header_ [class_ "heading"] $ do
            h1_ (toHtml $ title $ content)
            p_ (headersentence content)
            topiclist_

assembleGlossary :: Article [Text] -> Assembler (Lucid.Html ())
assembleGlossary art = do
    gd <- fmap extract <$> jsonm @() @GlossaryData art isGlossary
    let out = maybe mempty r gd
    pure out
  where
    r :: GlossaryData -> Lucid.Html ()
    r gd =
        dl_ [class_ "glossary"] $ do
            mconcat $ fmap rterm (glossary gd)

    rterm :: GlossaryTerm -> Lucid.Html ()
    rterm (GlossaryTerm t d) = do
        dt_ [class_ "term"] (toHtml t)
        dd_ [class_ "definition"] (toHtml d)

assembleFooter :: Article [Text] -> Assembler (Lucid.Html ())
assembleFooter a = r <$> (fmap extract . jsonSection @SocialData =<< getSection a isSocial)
  where
    r :: SocialData -> Lucid.Html ()
    r s =
        footer_ [class_ "footing"] $ do
            div_ [class_ "social-links"] $ do
                maybe mempty twtr (twitter s)
                maybe mempty masto (mastodon s)
                maybe mempty ghub (github s)
                maybe mempty lkdn (linkedin s)

    twtr, ghub, lkdn, masto :: Text -> Lucid.Html ()
    twtr h = a_ [href_ $ "https://twitter.com/" <> h] "twitter"
    ghub h = a_ [href_ $ "https://github.com/" <> h] "github"
    lkdn h = a_ [href_ $ "https://linkedin.com/in/" <> h] "linkedin"
    masto h = a_ [rel_ "me", href_ $ h] "mastodon"

assembleDefaultLayoutWarning :: Article [Text] -> Assembler (Lucid.Html ())
assembleDefaultLayoutWarning _ = pure $ do
    div_ [class_ "no-layout-notice"] $ do
        p_ "this page uses a default layout"

assembleUpcomingMain :: UrlPrefix -> Article [Text] -> Assembler (Lucid.Html ())
assembleUpcomingMain urlPrefix a = r <$> renderMainSections urlPrefix a
  where
    r :: [Lucid.Html ()] -> Lucid.Html ()
    r content = do
        div_ [class_ "upcoming-notice"] $ do
            p_ "This article is still considered unfinished and content may change significantly."
        div_ [id_ "histogram"] mempty
        div_ [class_ "main-article"] $ do
            mconcat content

assembleArchivedMain :: UrlPrefix -> Article [Text] -> Assembler (Lucid.Html ())
assembleArchivedMain urlPrefix a = r <$> renderMainSections urlPrefix a
  where
    r :: [Lucid.Html ()] -> Lucid.Html ()
    r content = do
        div_ [class_ "archived-notice"] $ do
            p_ "This article is considered archived."
        div_ [id_ "histogram"] mempty
        div_ [class_ "main-article"] $ do
            mconcat content

assembleMain :: UrlPrefix -> Article [Text] -> Assembler (Lucid.Html ())
assembleMain urlPrefix a = r <$> renderMainSections urlPrefix a
  where
    r :: [Lucid.Html ()] -> Lucid.Html ()
    r content =
        div_ [class_ "main-article"] $ do
            mconcat content

-- | Renders `main-content` sections (as markdown) alongside `callout`, `faq`,
-- and `pricing` sections (as their own widgets), in the order they appear in
-- the source file.
renderMainSections :: UrlPrefix -> Article [Text] -> Assembler [Lucid.Html ()]
renderMainSections urlPrefix a = getSections a isRenderableContent >>= traverse (renderContentSection urlPrefix)
  where
    isRenderableContent s = isMainContent s || isCallout s || isFaq s || isPricing s

{- | Rewrites root-relative @href="/..."@ and @src="/..."@ attribute values in
an already-rendered HTML fragment to carry the site's @basePath@. This is
what makes a plain markdown link like @[home](\/index.html)@, written by an
author with no idea the site might be hosted under a subpath, resolve
correctly there. Protocol-relative (@\/\/...@), scheme-qualified
(@https:\/\/...@), and fragment (@#...@) URLs are left untouched, since none
of them start with a single @\/@.
-}
prefixRootRelativeLinks :: UrlPrefix -> Text -> Text
prefixRootRelativeLinks "" html = html
prefixRootRelativeLinks urlPrefix html =
    rewriteAttr "src=\"" $ rewriteAttr "href=\"" html
  where
    rewriteAttr :: Text -> Text -> Text
    rewriteAttr marker = go
      where
        go t = case Text.breakOn marker t of
            (before, rest)
                | Text.null rest -> before
                | otherwise ->
                    let afterMarker = Text.drop (Text.length marker) rest
                        isRootRelative = "/" `Text.isPrefixOf` afterMarker && not ("//" `Text.isPrefixOf` afterMarker)
                        inserted = if isRootRelative then urlPrefix else ""
                     in before <> marker <> inserted <> go afterMarker

renderContentSection :: UrlPrefix -> Section [Text] -> Assembler (Lucid.Html ())
renderContentSection urlPrefix s
    | isMainContent s = do
        rendered <- renderSection s
        pure $ section_ [class_ "main-section"] $ toHtmlRaw @Text (prefixRootRelativeLinks urlPrefix $ extract' rendered)
    | isCallout s = do
        calloutSection <- jsonSection @CalloutData s
        pure $ calloutHtml (extract calloutSection)
    | isFaq s = do
        faqSection <- jsonSection @FaqData s
        pure $ faqHtml (extract faqSection)
    | isPricing s = do
        pricingSection <- jsonSection @PricingTableData s
        pure $ pricingHtml (extract pricingSection)
    | otherwise = CoreAssembler.Assembler $ Left (CoreAssembler.UnsupportedConversionFormat (sectionFormat s))

calloutHtml :: CalloutData -> Lucid.Html ()
calloutHtml c =
    div_ [class_ ("callout callout-" <> calloutKind c)] $ do
        p_ [class_ "callout-title"] (toHtml $ fromMaybe (calloutDefaultTitle $ calloutKind c) (calloutTitle c))
        p_ [class_ "callout-body"] (toHtml $ calloutBody c)

calloutDefaultTitle :: Text -> Text
calloutDefaultTitle "note" = "Note"
calloutDefaultTitle "warning" = "Warning"
calloutDefaultTitle "tip" = "Tip"
calloutDefaultTitle "important" = "Important"
calloutDefaultTitle "danger" = "Danger"
calloutDefaultTitle other = other

faqHtml :: FaqData -> Lucid.Html ()
faqHtml d =
    div_ [class_ "faq"] $ do
        mconcat [faqItemHtml i | i <- items d]

faqItemHtml :: FaqItem -> Lucid.Html ()
faqItemHtml i =
    details_ [class_ "faq-item"] $ do
        summary_ [class_ "faq-question"] (toHtml $ question i)
        p_ [class_ "faq-answer"] (toHtml $ answer i)

-- | Renders as an actual `<table>`: one column per plan, one row per
-- feature. A plan's `values` are matched to `pricingFeatures` positionally;
-- mismatched lengths degrade gracefully to a ragged table rather than erroring.
pricingHtml :: PricingTableData -> Lucid.Html ()
pricingHtml d =
    table_ [class_ "pricing-table"] $ do
        thead_ $ tr_ $ do
            th_ [class_ "pricing-feature-header"] mempty
            mconcat [planHeader p | p <- pricingPlans d]
        tbody_ $ do
            mconcat [featureRow feat vals | (feat, vals) <- List.zip (pricingFeatures d) (List.transpose (fmap values (pricingPlans d)))]
  where
    planHeader :: PricingPlan -> Lucid.Html ()
    planHeader p =
        th_ [class_ "pricing-plan-header"] $ do
            div_ [class_ "pricing-plan-name"] (toHtml $ name p)
            div_ [class_ "pricing-plan-price"] (toHtml $ price p)

    featureRow :: Text -> [Text] -> Lucid.Html ()
    featureRow feat vals =
        tr_ $ do
            td_ [class_ "pricing-feature-name"] (toHtml feat)
            mconcat [td_ [class_ "pricing-value"] (toHtml v) | v <- vals]

assembleStyle :: Article [Text] -> Assembler (Lucid.Html ())
assembleStyle a = r <$> (fmap Text.concat <$> getSection a isMainCss)
  where
    r :: Section Text -> Lucid.Html ()
    r content = style_ (extract content)

assembleTopicListing :: UrlPrefix -> OutputPrefix -> TopicStats -> TopicName -> [(Target a, Article [Text])] -> Assembler (Lucid.Html ())
assembleTopicListing urlPrefix prefix stats topic articles =
    pure r
  where
    r :: Lucid.Html ()
    r = do
        header_ [class_ "heading"] $ do
            h1_ $ toHtml topic
        section_ [class_ "main"] $ do
            mainArticleLinks urlPrefix articles
        section_ [class_ "others"]
            $ mconcat
                [ h2_ [class_ "listing-callout"] "other topics"
                , div_ [class_ "topiclist"]
                    $ mconcat
                        [ topicListingTag urlPrefix prefix stats otherTopic | otherTopic <- Map.keys $ byTopic stats, otherTopic /= topic
                        ]
                ]

topicListingTag :: UrlPrefix -> OutputPrefix -> TopicStats -> TopicName -> Lucid.Html ()
topicListingTag urlPrefix prefix stats topic =
    div_ [class_ "topic"] $ do
        a_ [class_ "topic-link", href_ url] $ do
            span_ [class_ "topic-name"] (toHtml topic)
            span_ [class_ "topic-count"] $ do
                toHtml $ show $ length articles
  where
    url = destinationUrl $ destTopic urlPrefix prefix topic
    articles = fromMaybe [] $ Map.lookup topic $ byTopic stats

type TagValue = Text.Text

assembleHashtagListing :: UrlPrefix -> TagValue -> [(Target a, Article [Text])] -> Assembler (Lucid.Html ())
assembleHashtagListing urlPrefix tag articles =
    pure r
  where
    r :: Lucid.Html ()
    r = do
        header_ [class_ "heading"] $ do
            h1_ $ toHtml tag
        section_ [class_ "main"] $ do
            mainArticleLinks urlPrefix articles

assembleGlossaryListing :: OutputPrefix -> WholeGlossary -> [(Target a, Article [Text])] -> Assembler (Lucid.Html ())
assembleGlossaryListing _ g _ =
    pure r
  where
    r :: Lucid.Html ()
    r = do
        header_ [class_ "heading"] $ do
            h1_ $ toHtml ("Glossary" :: Text)
        section_ [class_ "main"] $ do
            mconcat [entry t arts | (t, arts) <- glossaryEntries g]

    entry :: Text -> [(Target (), Article [Text], Text)] -> Lucid.Html ()
    entry t defs = do
        div_ [class_ "entry"] $ do
            p_ $ toHtml t
            defWithLinks defs

    defWithLinks :: [(Target (), Article [Text], Text)] -> Lucid.Html ()
    defWithLinks defs =
        do
            ul_ [class_ "entry-details-list"]
            $ mconcat
                [ li_ [class_ "article-details-list-item"] $ defWithLink t art def
                | (t, art, def) <- defs
                ]

    defWithLink :: Target () -> Article [Text] -> Text -> Lucid.Html ()
    defWithLink t art def =
        div_ [class_ "deflink"] $ do
            p_ $ toHtml def
            span_ $ do
                toHtml ("In: " :: Text)
                articleLink t art
                toHtml ("." :: Text)

htmlbody ::
    (Article [Text] -> Assembler (Lucid.Html ())) ->
    (Article [Text] -> Assembler (Lucid.Html ()))
htmlbody = wrap body_

data MetaHeaders = MetaHeaders
    { extraMeta :: MetaData
    , destinationHTML :: DestinationLocation
    , destinationJSON :: DestinationLocation
    , destinationText :: DestinationLocation
    , destinationAtom :: DestinationLocation
    }

htmlhead ::
    MetaHeaders ->
    (Article [Text] -> Assembler (Lucid.Html ())) ->
    (Article [Text] -> Assembler (Lucid.Html ()))
htmlhead mh f = \art -> do
    hdrs1 <- metaheaders mh art
    hdrs2 <- f art
    hdrs3 <- (extraHeaders mh.extraMeta) art
    pure $ head_ (hdrs1 >> hdrs2 >> hdrs3)

-- see https://ogp.me/#types
-- see https://webcode.tools/generators/open-graph/article
-- see https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started
-- see https://www.linkedin.com/post-inspector/inspect/
metaheaders :: MetaHeaders -> Article [Text] -> Assembler (Lucid.Html ())
metaheaders mh art = do
    topic <- fmap extract <$> jsonm @() @TopicData art isTopic
    social <- fmap extract <$> jsonm @() @SocialData art isSocial
    summary <- fmap extract <$> lookupSection art isSummary
    preamble <- fmap extract <$> jsonm @() @PreambleData art isPreamble
    let titleTxt = maybe defaultTitle mktitle preamble :: Text
    let faviconHref = mkfavicon preamble :: Text
    let twitterCard = case topic >>= imageLink of
            Just _ -> "summary_large_image" :: Text
            Nothing -> "summary"
    let modifiedTime = case preamble >>= updated of
            Just x -> Just x
            Nothing -> preamble >>= date
    let robotsContent = case buildinfo art >>= robots of
            Just x -> x
            Nothing -> case publicationStatus =<< buildinfo art of
                Just Upcoming -> "noindex, nofollow"
                Just Archived -> "noindex, nofollow"
                Just Public -> "index, follow"
                Nothing -> "index, follow"
    let jsonLd =
            flip fmap preamble $ \p ->
                object
                    $ catMaybes
                        [ Just $ "@context" .= ("https://schema.org" :: Text)
                        , Just $ "@type" .= ("BlogPosting" :: Text)
                        , Just $ "mainEntityOfPage" .= object ["@type" .= ("WebPage" :: Text), "@id" .= urlForPage]
                        , Just $ "headline" .= title p
                        , fmap (\x -> "description" .= compactSummary x) summary
                        , fmap (\link -> "image" .= [urlForImage link]) (topic >>= imageLink)
                        , fmap (\d -> "datePublished" .= fmtUTC d) (date p)
                        , fmap (\d -> "dateModified" .= fmtUTC d) modifiedTime
                        , Just $ "author" .= object ["@type" .= ("Person" :: Text), "name" .= author p]
                        , Just $ "publisher" .= object ["@type" .= ("Organization" :: Text), "name" .= baseTitle extra]
                        ]
    pure
        $ mconcat
        $ catMaybes
            [ Just $ meta_ [charset_ "utf-8"]
            , Just $ meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1.0"]
            , Just $ meta_ [name_ "robots", content_ robotsContent]
            , Just $ title_ $ toHtml titleTxt
            , Just $ link_ [rel_ "icon", type_ "image/x-icon", href_ faviconHref]
            , Just $ link_ [rel_ "canonical", href_ urlForPage]
            , Just $ link_ [rel_ "alternate", type_ "application/atom+xml", title_ "Atom Feed", href_ urlForAtom]
            , fmap (\x -> meta_ [name_ "author", content_ $ author x]) preamble
            , fmap (\x -> meta_ [name_ "keywords", content_ $ Text.intercalate ", " $ topicKeywords x]) topic
            , fmap (\x -> meta_ [name_ "description", content_ $ compactSummary x]) summary
            , Just $ meta_ [name_ "twitter:card", content_ twitterCard]
            , Just $ meta_ [name_ "twitter:title", content_ titleTxt]
            , (\x -> meta_ [name_ "twitter:site", content_ $ "@" <> x]) <$> twitterSiteLogin extra
            , (\x -> meta_ [name_ "twitter:creator", content_ $ "@" <> x]) <$> (twitter =<< social)
            , fmap (\x -> meta_ [property_ "twitter:description", content_ $ compactSummary x]) summary
            , Just $ meta_ [name_ "type", property_ "og:type", content_ "article"]
            , Just $ meta_ [name_ "title", property_ "og:title", content_ titleTxt]
            , Just $ meta_ [property_ "og:site_name", content_ $ baseTitle extra]
            , Just $ meta_ [name_ "url", property_ "og:url", content_ $ urlForPage]
            , fmap (\x -> meta_ [property_ "og:image", content_ $ urlForImage $ x]) (topic >>= imageLink)
            , fmap (\x -> meta_ [property_ "twitter:image:src", content_ $ urlForImage $ x]) (topic >>= imageLink)
            , fmap (\x -> meta_ [property_ "og:description", content_ $ compactSummary x]) summary
            , fmap (\x -> meta_ [name_ "article:published_time", content_ $ fmtUTC x]) $ preamble >>= date
            , fmap (\x -> meta_ [name_ "article:modified_time", content_ $ fmtUTC x]) modifiedTime
            , Just $ meta_ [name_ "ks:article_json", content_ $ urlForJSONPage]
            , Just $ meta_ [name_ "ks:article_text", content_ $ urlForTextPage]
            , fmap (\v -> script_ [type_ "application/ld+json"] (toHtmlRaw $ renderJsonLd v)) jsonLd
            ]
        <> [meta_ [name_ "article:tag", content_ k] | k <- maybe [] topicKeywords topic]
  where
    extra = mh.extraMeta
    dloc = mh.destinationHTML
    jsondloc = mh.destinationJSON
    textdloc = mh.destinationText
    atomLoc = mh.destinationAtom
    fmtUTC = Text.pack . iso8601Show
    property_ = Lucid.makeAttribute "property"
    urlForPage = publishBaseURL extra <> destinationUrl dloc
    urlForAtom = publishBaseURL extra <> destinationUrl atomLoc
    urlForJSONPage = destinationUrl jsondloc
    urlForTextPage = destinationUrl textdloc
    urlForImage imgpath = publishBaseURL extra <> imgpath

    defaultTitle :: Text
    defaultTitle = baseTitle extra

    mktitle :: PreambleData -> Text
    mktitle x = mconcat [baseTitle extra, " - ", title x]

    mkfavicon :: Maybe PreambleData -> Text
    mkfavicon x = fromMaybe (extra.pathPrefix <> defaultFavicon) (faviconUrl =<< x)

defaultFavicon :: Text
defaultFavicon = "/images/favicon.png"

-- | Renders JSON-LD for embedding in a <script type="application/ld+json">
-- tag. "</" is escaped because a literal "</script>" inside a JSON string
-- value (e.g. an article title) would otherwise close the tag early.
renderJsonLd :: Value -> Text
renderJsonLd = Text.replace "</" "<\\/" . LText.toStrict . encodeToLazyText

wrap ::
    (Lucid.Html () -> Lucid.Html ()) ->
    (Article [Text] -> Assembler (Lucid.Html ())) ->
    (Article [Text] -> Assembler (Lucid.Html ()))
wrap k f = fmap k . f

htmldoc ::
    (Article [Text] -> Assembler (Lucid.Html ())) ->
    Article [Text] ->
    Assembler LText.Text
htmldoc mkContent art =
    Lucid.renderText . withLang <$> mkContent art
  where
    withLang content = doctype_ *> html_ [lang_ langCode] content
    langCode = fromMaybe defaultLang (lang =<< articlePreambleData art)

defaultLang :: Text
defaultLang = "en"

buildinfo :: Article [Text] -> Maybe BuildInfoData
buildinfo art =
    either (const Nothing) Just
        $ fmap extract
        $ runAssembler
        $ json @() @BuildInfoData art isBuildInfo

isPublishedArticle :: Article [Text] -> Bool
isPublishedArticle art = isPublic
  where
    isPublic :: Bool
    isPublic = case publicationStatus =<< buildinfo art of
        Just Upcoming -> False
        Just Archived -> False
        Just Public -> True
        Nothing -> True

isListableArticle :: Article [Text] -> Bool
isListableArticle art = not (layoutNameFor art `List.elem` [IndexPage, TopicListingTemplate, HashTagListingTemplate])

shouldShowStatsForArticle :: Article [Text] -> Bool
shouldShowStatsForArticle art =
    not (layoutNameFor art `List.elem` [IndexPage, HashTagListingTemplate, TopicListingTemplate, ImageGallery, VariousListing, SinglePageApp])

isConcreteTarget :: Article [Text] -> Bool
isConcreteTarget art = not (isSpecialLayout (layoutNameFor art))

compactTitle :: PreambleData -> Text
compactTitle p = mconcat [title p]

articleLink :: Target a -> Article [Text] -> Lucid.Html ()
articleLink (Core.Target d _ _) art =
    mylink_ url txt
  where
    url :: Text
    url = destinationUrl d

    txt :: Text
    txt =
        either (const url) (compactTitle . extract)
            $ runAssembler
            $ json @() @PreambleData art isPreamble

mylink_ :: Url -> Text -> Lucid.Html ()
mylink_ url txt = a_ [href_ url] (toHtml txt)

-- | The link back to the site root, labelled (and optionally decorated with an
-- icon) according to the site's @homeLink@ configuration.
homeLink :: MetaData -> Lucid.Html ()
homeLink meta =
    a_ [href_ (meta.pathPrefix <> "/"), class_ "home-link"] $ do
        traverse_ (\icon -> img_ [src_ (iconUrl icon), alt_ ""]) spec.homeIcon
        toHtml spec.homeLabel
  where
    spec = meta.homeLinkSpec
    iconUrl = siteUrl meta

-- | Only a root-relative URL follows the site's basePath; absolute
-- (https://..., //...) and relative URLs are left alone.
siteUrl :: MetaData -> Text -> Text
siteUrl meta u
    | "/" `Text.isPrefixOf` u && not ("//" `Text.isPrefixOf` u) = meta.pathPrefix <> u
    | otherwise = u

-- | The site-wide header menu, from the @menu@ of @kitchen-sink.json@ (nothing
-- when unset).
siteMenu :: MetaData -> Lucid.Html ()
siteMenu meta
    | null meta.menuSpec = mempty
    | otherwise = ul_ [class_ "site-menu"] $ traverse_ item meta.menuSpec
  where
    item :: MenuItem -> Lucid.Html ()
    item i = li_ $ do
        a_ [href_ (siteUrl meta i.menuUrl)] (toHtml i.menuLabel)
        when (not (null i.menuChildren))
            $ ul_ [class_ "site-submenu"]
            $ traverse_ item i.menuChildren

-- | The site-wide footer, from the @footer@ of @kitchen-sink.json@ (nothing
-- when unset).
siteFooter :: MetaData -> Lucid.Html ()
siteFooter meta = case meta.footerSpec of
    Nothing -> mempty
    Just spec -> footer_ [class_ "site-footer"] $ do
        when (not (null spec.footerColumns))
            $ div_ [class_ "footer-columns"]
            $ traverse_ column spec.footerColumns
        traverse_ (\t -> p_ [class_ "footer-legal"] (toHtml t)) spec.footerLegal
  where
    column :: FooterColumn -> Lucid.Html ()
    column c = div_ [class_ "footer-column"] $ do
        traverse_ (h2_ . toHtml) c.footerHeading
        ul_ $ traverse_ (\l -> li_ $ a_ [href_ (siteUrl meta l.menuUrl)] (toHtml l.menuLabel)) c.footerLinks

-- | The `data-base-path` attribute is read by search-box.js at startup
-- (see @KitchenSink.getBasePath@ in purs/kitchen-sink-compat) so it fetches
-- @paths.json@ from the right place under a `basePath` deployment.
searchBox :: UrlPrefix -> Lucid.Html ()
searchBox urlPrefix = div_ [id_ "search-box", data_ "base-path" urlPrefix] $ do
    js_ (urlPrefix <> "/js/search-box.js")

js_ :: Text -> Lucid.Html ()
js_ p = Lucid.termRawWith "script" [type_ "text/javascript", src_ p, async_ ""] ""

topicsListings :: TopicStats -> Lucid.Html ()
topicsListings stats =
    nav_ [class_ "topics-listing"] $ do
        h2_ [class_ "listing-callout"] "by topic"
        div_ [class_ "topics-listing-list"] $ do
            mconcat
                [ topicListing p
                | p <- Map.toList $ byTopic stats
                ]

topicListing :: (TopicName, [(Target a, Article [Text])]) -> Lucid.Html ()
topicListing (topic, tgts) =
    div_ [class_ "topics-listing-list-topic"] $ do
        strong_ [class_ "topic-name"] (toHtml topic)
        smallArticleLinks tgts

smallArticleLinks :: [(Target a, Article [Text])] -> Lucid.Html ()
smallArticleLinks tgts =
    ul_ [class_ "article-links-list"]
        $ mconcat
            [li_ [class_ "article-links-list-item"] $ uncurry articleLink t | t <- tgts]

topicTag :: UrlPrefix -> OutputPrefix -> TopicStats -> DestinationLocation -> TopicName -> Lucid.Html ()
topicTag urlPrefix prefix stats currentDestination topic =
    div_ [class_ "topic"] $ do
        navArrow "topic-prev-link" "‹" prevUrl
        a_ [class_ "topic-link", href_ url] $ do
            span_ [class_ "topic-name"] (toHtml topic)
            span_ [class_ "topic-count"] $ do
                toHtml $ show $ 1 + length prevArticles
                toHtml ("/" :: Text)
                toHtml $ show $ length articles
        navArrow "topic-next-link" "›" nextUrl
  where
    url = destinationUrl $ destTopic urlPrefix prefix topic
    articles = fromMaybe [] $ Map.lookup topic $ byTopic stats
    -- todo: extract the following into separate functions
    isOtherTarget (target2, _) = destination target2 /= currentDestination
    prevArticles = List.takeWhile isOtherTarget articles
    nextArticles = List.drop 1 $ List.dropWhile isOtherTarget articles
    previousArticle = if null prevArticles then Nothing else Just $ List.last prevArticles
    nextArticle = fst <$> List.uncons nextArticles

    prevUrl = destinationUrl . destination . fst <$> previousArticle
    nextUrl = destinationUrl . destination . fst <$> nextArticle

    -- A pagination-style arrow: a clickable "‹"/"›" when there is a
    -- neighbouring article, otherwise a static "|" marking the end of the topic.
    navArrow :: Text -> Text -> Maybe Url -> Lucid.Html ()
    navArrow cls arrow mUrl =
        span_ [class_ ("topic-nav " <> cls)] $ do
            case mUrl of
                Just u -> a_ [class_ "topic-nav-link", href_ u] (toHtml arrow)
                Nothing -> span_ [class_ "topic-nav-boundary"] (toHtml ("|" :: Text))

sortByDate :: [(a, Article [Text])] -> [(a, Article [Text])]
sortByDate = List.sortBy f
  where
    f (_, a1) (_, a2) = extractDate a2 `compare` extractDate a1

extractDate :: Article [Text] -> Maybe UTCTime
extractDate art =
    either (const Nothing) (date . extract)
        $ runAssembler
        $ json @() @PreambleData art isPreamble

mainArticleLinks :: UrlPrefix -> [(Target a, Article [Text])] -> Lucid.Html ()
mainArticleLinks urlPrefix targets =
    articleListing urlPrefix "all articles"
        $ List.filter (isListableArticle . snd)
        $ sortByDate targets

latestArticleLink :: UrlPrefix -> [(Target a, Article [Text])] -> Lucid.Html ()
latestArticleLink urlPrefix targets =
    articleListing urlPrefix "latest article"
        $ List.take 1
        $ List.filter (isPublishedArticle . snd)
        $ List.filter (isListableArticle . snd)
        $ sortByDate targets

articleListing :: UrlPrefix -> Text -> [(Target a, Article [Text])] -> Lucid.Html ()
articleListing urlPrefix htext targets =
    nav_ [class_ "articles-listing"] $ do
        h2_ [class_ "listing-callout"] $ Lucid.toHtml htext
        div_ [class_ "articles-listing-list"] $ do
            mconcat
                [ uncurry (mainArticleLinkWithAnnotation urlPrefix) p
                | p <- targets
                ]

siteGraphEchartZone :: UrlPrefix -> Lucid.Html ()
siteGraphEchartZone urlPrefix =
    nav_ [class_ "articles-graph"] $ do
        h2_ [class_ "listing-callout"] "site map"
        div_ [id_ "echartzone", data_ "base-path" urlPrefix, style_ "width:800px;height:600px;"] mempty

mainArticleLinkWithAnnotation :: UrlPrefix -> Target a -> Article [Text] -> Lucid.Html ()
mainArticleLinkWithAnnotation urlPrefix t art =
    div_ [class_ $ mconcat ["articles-listing-item", " ", publishStatusClass]] $ do
        span_ [class_ "article-date"] (toHtml formattedDate)
        div_ $ do
            statusIcon
            articleLink t art
            when (isPublishedArticle art) $ do
                articleSummary urlPrefix art
  where
    formattedDate :: Text
    formattedDate = fromMaybe "" $ preambleDateText =<< preamble

    publishStatusClass :: Text
    publishStatusClass = fromMaybe "" $ fmap f $ publicationStatus =<< buildinfo art
      where
        f Public = "article-status-published"
        f Upcoming = "article-status-upcoming"
        f Archived = "article-status-archived"

    statusIcon :: Lucid.Html ()
    statusIcon = case publicationStatus =<< buildinfo art of
        Nothing -> mempty
        Just Public -> mempty
        Just Upcoming -> span_ [class_ "notice-upcoming"] "(upcoming)"
        Just Archived -> span_ [class_ "notice-archived"] "(archived)"

    preamble :: Maybe PreambleData
    preamble =
        either (const Nothing) Just
            $ fmap extract
            $ runAssembler
            $ json @() @PreambleData art isPreamble

preambleDateText :: PreambleData -> Maybe Text
preambleDateText preamble =
    (datetxt preamble)
        <|> (fmtUTC <$> date preamble)
  where
    fmtUTC :: UTCTime -> Text
    fmtUTC = Text.pack . formatTime defaultTimeLocale "%a, %d %b %Y"

articleSummary :: UrlPrefix -> Article [Text] -> Lucid.Html ()
articleSummary urlPrefix art = when (isJust x) $ do
    div_ [class_ "article-summary"] $ do
        when (shouldShowStatsForArticle art) $ do
            articleStats art
        articleImage art
        maybe mempty (toHtmlRaw @Text . prefixRootRelativeLinks urlPrefix . extract') x
  where
    x :: Maybe (Section PreRenderedHtml)
    x = fromRight Nothing $ runAssembler (lookupSection art isSummary >>= traverse renderSection)

articleStats :: Article [Text] -> Lucid.Html ()
articleStats art = do
    div_ [class_ "article-stats"] $ do
        p_
            $ toHtml
            $ List.intercalate
                " "
                [ show wc
                , "words"
                , "/"
                , show . length . linkInfos $ infos
                , "links"
                , "/"
                , show . length . imageInfos $ infos
                , "images"
                , "/"
                , show . length . snippetInfos $ infos
                , "snippets"
                ]
  where
    infos = analyzeArticle art

    wc :: Int
    wc = contentWordCount xs

    xs :: [Section [Text]]
    xs = fromRight [] $ runAssembler (getSections art isMainContent)

articleImage :: Article [Text] -> Lucid.Html ()
articleImage art = do
    div_ [class_ "article-image"] $ do
        maybe mempty (\link -> img_ [src_ link, alt_ altText]) imgUrl
  where
    imgUrl :: Maybe Text
    imgUrl = imageLink =<< topicData

    altText :: Text
    altText =
        fromMaybe (maybe "" title $ articlePreambleData art)
            $ imageAlt =<< topicData

    topicData :: Maybe TopicData
    topicData = case runAssembler getTopic of
        Left _ -> Nothing
        Right x -> x

    getTopic :: Assembler (Maybe TopicData)
    getTopic = f <$> jsonm @() @TopicData art isTopic

    f :: Maybe (Section TopicData) -> Maybe TopicData
    f sec = extract <$> sec

assembleAtomEntry ::
    MetaData ->
    DestinationLocation ->
    Article [Text] ->
    Assembler (Atom.Entry)
assembleAtomEntry extra dloc art = do
    summary <- fmap (compactSummary . extract) <$> lookupSection art isSummary
    contents <- assembleMain extra.pathPrefix art
    r
        <$> (extract <$> json @() @PreambleData art isPreamble)
        <*> pure summary
        <*> pure contents
  where
    r :: PreambleData -> Maybe Text -> Html () -> Atom.Entry
    r preamble summary htmlContents =
        let url = publishBaseURL extra <> destinationUrl dloc
            selfLink = (Atom.nullLink url){Atom.linkRel = Just $ Left "alternate"}
            base =
                Atom.nullEntry
                    (url)
                    (Atom.TextString preamble.title)
                    (fmtUTC $ fromMaybe epochUTCTime preamble.date)
         in base
                { Atom.entrySummary = fmap Atom.TextString summary
                , Atom.entryAuthors = [Atom.nullPerson{Atom.personName = author preamble}]
                , Atom.entryLinks = [selfLink]
                , Atom.entryContent = Just (Atom.HTMLContent $ LText.toStrict $ Lucid.renderText htmlContents)
                }

    fmtUTC = Text.pack . iso8601Show

assemblePreambleData :: Article [Text] -> Assembler (Maybe PreambleData)
assemblePreambleData art = do
    fmap extract <$> jsonm @() @PreambleData art isPreamble

articlePreambleData :: Article [Text] -> Maybe PreambleData
articlePreambleData =
    join . hush . runAssembler . assemblePreambleData

assembleTopicData :: Article [Text] -> Assembler (Maybe TopicData)
assembleTopicData art = do
    fmap extract <$> jsonm @() @TopicData art isTopic

articleTopicData :: Article [Text] -> Maybe TopicData
articleTopicData =
    join . hush . runAssembler . assembleTopicData

assembleGlossaryData :: Article [Text] -> Assembler (Maybe GlossaryData)
assembleGlossaryData art = do
    fmap extract <$> jsonm @() @GlossaryData art isGlossary

articleGlossaryData :: Article [Text] -> Maybe GlossaryData
articleGlossaryData =
    join . hush . runAssembler . assembleGlossaryData

compactSummary :: [Text] -> Text
compactSummary = Text.strip . Text.intercalate " "

assembleCompactSummary :: Article [Text] -> Assembler Text
assembleCompactSummary art = do
    summary <- fmap extract <$> lookupSection art isSummary
    pure $ maybe "" compactSummary summary

assembleTitle :: Article [Text] -> Assembler (Maybe Text)
assembleTitle art = do
    preamble <- fmap extract <$> jsonm @() @PreambleData art isPreamble
    pure (title <$> preamble)

articleCompactSummary :: Article [Text] -> Maybe Text
articleCompactSummary =
    hush . runAssembler . assembleCompactSummary

articleTitle :: Article [Text] -> Maybe Text
articleTitle =
    join . hush . runAssembler . assembleTitle

-- | A heading of a documentation page, as listed in its table of contents.
data OutlineEntry = OutlineEntry
    { outlineLevel :: Int
    , outlineId :: Text
    , outlineLabel :: Text
    -- ^ the heading text as HTML (already escaped by the markdown renderer)
    }
    deriving (Show, Eq)

{- | The @h2@ and @h3@ headings that carry an @id@ (as given by the markdown
renderer's automatic identifiers) in an already-rendered HTML fragment.
-}
headingOutline :: Text -> [OutlineEntry]
headingOutline t =
    case Text.breakOn "<h" t of
        (_, rest) | Text.null rest -> []
        (_, rest) ->
            let afterTag = Text.drop 2 rest
             in case Text.uncons afterTag of
                    Just (c, attrsAndBody)
                        | c == '2' || c == '3' ->
                            let (attrs, afterOpen) = Text.breakOn ">" attrsAndBody
                                (body, afterClose) = Text.breakOn ("</h" <> Text.singleton c <> ">") (Text.drop 1 afterOpen)
                                entry = OutlineEntry (if c == '2' then 2 else 3) <$> attributeValue "id" attrs <*> pure (stripTags body)
                             in maybe (headingOutline afterClose) (: headingOutline afterClose) entry
                    _ -> headingOutline afterTag
  where
    attributeValue :: Text -> Text -> Maybe Text
    attributeValue name attrs =
        case Text.breakOn (name <> "=\"") attrs of
            (_, r) | Text.null r -> Nothing
            (_, r) -> Just $ Text.takeWhile (/= '"') $ Text.drop (Text.length name + 2) r

stripTags :: Text -> Text
stripTags t =
    case Text.breakOn "<" t of
        (before, rest)
            | Text.null rest -> before
            | otherwise -> before <> stripTags (Text.drop 1 (Text.dropWhile (/= '>') rest))

-- | The sidebar of a documentation page: the headings of its @main-content@.
assembleDocumentationToc :: Article [Text] -> Assembler (Lucid.Html ())
assembleDocumentationToc art = do
    sections <- getSections art isMainContent
    rendered <- traverse renderSection sections
    let outline = List.concatMap (headingOutline . extract') rendered
    pure $ case outline of
        [] -> nav_ [class_ "doc-toc"] mempty
        _ ->
            nav_ [class_ "doc-toc"] $ do
                p_ [class_ "doc-toc-title"] "On this page"
                ul_ $ mconcat [entry e | e <- outline]
  where
    entry :: OutlineEntry -> Lucid.Html ()
    entry e =
        li_ [class_ ("doc-toc-level-" <> Text.pack (show e.outlineLevel))]
            $ a_ [href_ ("#" <> e.outlineId)] (toHtmlRaw e.outlineLabel)

{- | Documentation pages are ordered by the @order@ of their build-info, then
by title.
-}
documentationOrder :: Article [Text] -> (Int, Text)
documentationOrder art =
    (fromMaybe maxBound (order =<< buildinfo art), fromMaybe "" (articleTitle art))

-- | Links to the previous and next pages among those of the documentation layout.
documentationPager :: [(Target a, Article [Text])] -> DestinationLocation -> Lucid.Html ()
documentationPager targets current =
    nav_ [class_ "doc-pager"] $ do
        maybe mempty (link "doc-prev" "Previous: ") previous
        maybe mempty (link "doc-next" "Next: ") next
  where
    pages =
        List.sortOn (documentationOrder . snd)
            $ List.filter ((== DocumentationPage) . layoutNameFor . snd) targets

    isCurrent :: (Target a, Article [Text]) -> Bool
    isCurrent (t, _) = destinationUrl (destination t) == destinationUrl current

    (before, fromCurrent) = List.break isCurrent pages

    previous = if List.null before then Nothing else Just (List.last before)
    next = case fromCurrent of
        (_ : n : _) -> Just n
        _ -> Nothing

    link :: Text -> Text -> (Target b, Article [Text]) -> Lucid.Html ()
    link cls lbl (t, art) = div_ [class_ cls] $ do
        span_ [class_ "doc-pager-label"] (toHtml lbl)
        articleLink t art