unstatic.ztapir.simple.SimpleBlog.scala Maven / Gradle / Ivy
package unstatic.ztapir.simple
import java.time.{Instant, ZoneId, ZonedDateTime}
import scala.collection.*
import unstatic.{Site, *}
import unstatic.UrlPath.*
import unstatic.ztapir.*
import audiofluidity.rss.{Element, Itemable, LanguageCode, Namespace}
object SimpleBlog:
object Htmlifier:
val identity : Htmlifier = (s : String, opts : Options) => s
val preText : Htmlifier = (s : String, opts : Options) => s"${s}"
val defaultMarkdown : Htmlifier = (s : String, opts : Options) => Flexmark.defaultMarkdownToHtml(s, opts.generatorFullyQualifiedName )
case class Options( generatorFullyQualifiedName : Option[String] )
type Htmlifier = Function2[String,Htmlifier.Options,String]
end SimpleBlog
trait SimpleBlog extends ZTBlog:
import SimpleBlog.Htmlifier
object Entry:
val Presentation = Blog.EntryPresentation
type Presentation = Blog.EntryPresentation
final case class Info (
mbTitle : Option[String],
authors : Seq[String],
tags : Seq[String],
pubDate : Instant,
contentType : String,
mediaPathSiteRooted : Rooted, // from Site root
permalinkPathSiteRooted : Rooted // from Site root
)
final case class Input (
blog : SimpleBlog,
site : Site, // duplicative, since it's accessible from SiteLocations. But easy
renderLocation : SiteLocation,
mediaLocation : SiteLocation,
inferredInfo : Entry.Info,
presentation : Entry.Presentation
):
def entryById( id : String ) : EntryResolved = SimpleBlog.this.entryById(id)
end Entry
object Layout:
object Input:
case class Entry( blog : SimpleBlog, site : Site, renderLocation : SiteLocation, articleContentHtml : String, info : EntryInfo, sourceEntry : EntryResolved, previousEntry : Option[EntryResolved], nextEntry : Option[EntryResolved], presentation : SimpleBlog.this.Entry.Presentation )
case class Page( blog : SimpleBlog, site : Site, renderLocation : SiteLocation, mainContentHtml : String, sourceEntries : immutable.Seq[EntryResolved] )
end Input
end Layout
// you can override this
val timeZone: ZoneId = ZoneId.systemDefault()
// you can override this
val defaultSummaryAsDescriptionMaxLen = 500
def rssSummaryAsDescription(jsoupDocAbsolutized : org.jsoup.nodes.Document) : String =
val tmp = jsoupDocAbsolutized.text().take(defaultSummaryAsDescriptionMaxLen)
val lastSpace = tmp.lastIndexOf(' ')
(if lastSpace >= 0 then tmp.substring(0, lastSpace) else tmp) + "..."
def rssItemForResolved(resolved : EntryResolved, fullContent : Boolean) : Element.Item =
val entryInfo = resolved.entryInfo
val entryUntemplate = resolved.entryUntemplate
val absPermalink = site.absFromSiteRooted(resolved.entryInfo.permalinkPathSiteRooted)
val permalinkRelativeHtml = renderSingleFragment(SiteLocation(entryInfo.permalinkPathSiteRooted), resolved, Entry.Presentation.Rss)
val (absolutizedHtml, summary) =
val jsoupDoc = org.jsoup.Jsoup.parseBodyFragment(permalinkRelativeHtml, absPermalink.parent.toString)
mutateResolveRelativeUrls(jsoupDoc)
(jsoupDoc.body().html, rssSummaryAsDescription(jsoupDoc))
val nospamAuthor =
if entryInfo.authors.nonEmpty then s"""[email protected] (${entryInfo.authors.mkString(", ")})""" else "[email protected]"
val standardItem =
Element.Item(
title = Element.Title(resolved.entryInfo.mbTitle.getOrElse("")),
link = Element.Link(absPermalink.toString),
description = Element.Description(summary),
author = Element.Author(nospamAuthor),
categories = Nil,
comments = None,
enclosure = None,
guid = Some(Element.Guid(isPermalink = true, absPermalink.toString)),
pubDate = Some(Element.PubDate(entryInfo.pubDate.atZone(timeZone))),
source = None
)
if fullContent then standardItem.withExtra(Element.Content.Encoded(absolutizedHtml)) else standardItem
// you can override this
// should remain a def as long as we have lastBuildDate though
def channelSpec =
Element.Channel.Spec (
title = feedTitle,
linkUrl = frontPage.absolutePath.toString,
description = feedDescription,
language = Some(language),
lastBuildDate = Some(ZonedDateTime.now(timeZone)),
generator = Some("https://github.com/swaldman/unstatic"),
)
// better be def or lazy!
//
// if it's a val, it'll blow up trying to fetch the rssFeed SiteLocation before
// site has been initialized!
def atomLinkChannelExtra =
Element.Atom.Link(
href = rssFeed.absolutePath.toString(),
rel = Some(Element.Atom.LinkRelation.self),
`type` = Some("application/rss+xml")
)
def rssNamespaces = Namespace.RdfContent :: Namespace.DublinCore :: Namespace.Atom :: Nil
def feedToXmlSpec : Element.ToXml.Spec = Element.ToXml.Spec.Default
def makeFeed( fullContent : Boolean = true ) : Element.Rss =
given Itemable[EntryResolved] with
extension (resolved : EntryResolved)
def toItem : Element.Item = rssItemForResolved(resolved, fullContent)
val instantOrdering = summon[Ordering[Instant]]
val items =
( maxFeedEntries, onlyFeedEntriesSince ) match
case(Some(max), Some(since)) =>
entriesResolved
.filter( resolved => instantOrdering.compare(resolved.entryInfo.pubDate,since) > 0 )
.take(max)
case (None, Some(since)) =>
entriesResolved.filter( resolved => instantOrdering.compare(resolved.entryInfo.pubDate,since) > 0 )
case (Some(max), None) =>
entriesResolved.take(max)
case (None,None) =>
entriesResolved
val channel = Element.Channel.create( channelSpec, items ).withExtra( atomLinkChannelExtra )
Element.Rss(channel).overNamespaces(rssNamespaces)
// you can override this
val fullContentFeed = true
lazy val feed : Element.Rss = makeFeed( fullContentFeed )
lazy val feedXml : String = feed.asXmlText(feedToXmlSpec)
lazy val feedBytes : immutable.ArraySeq[Byte] = immutable.ArraySeq.unsafeWrapArray( feedXml.getBytes(CharsetUTF8) )
private val DefaultHtmlifierForContentType = immutable.Map[String,Htmlifier] (
"text/html" -> Htmlifier.identity,
"text/markdown" -> Htmlifier.defaultMarkdown,
"text/plain" -> Htmlifier.preText
)
// you can override this
def htmlifierForContentType(contentType : String) : Option[Htmlifier] =
DefaultHtmlifierForContentType.get( contentType )
type EntryInfo = Entry.Info
type EntryInput = Entry.Input
type EntryMetadata = Nothing
/**
* Reverse-chronological!
*/
given entryOrdering : Ordering[EntryResolved] =
Ordering.by( (er : EntryResolved) => (er.entryInfo.pubDate, er.entryUntemplate.UntemplateFullyQualifiedName) ).reverse
val site : Site // the type is Blog.this.Site, narrowed to ZTSite by ZTBlog
val frontPage : SiteLocation
def maxFeedEntries : Option[Int] = maxFrontPageEntries
def onlyFeedEntriesSince : Option[Instant] = None
// you must override this
val feedTitle : String
// you can override this
def feedDescription = s"Feed for blog '${feedTitle}' generated by unstatic"
def feedLinkHtml = s""""""
// you can override this
val language = LanguageCode.EnglishUnitedStates
lazy val rssFeed : SiteLocation = SiteLocation(frontPage.siteRootedPath.resolveSibling("feed.rss") )
private val DefaultRssFeedIdentifiers = immutable.Set("blogRssFeed")
// you can override this
def defaultAuthors : immutable.Seq[String] = Nil
// you can override this
def rssFeedIdentifiers = DefaultRssFeedIdentifiers
/**
* Filter the index of your untemplates for the blog's entries
*/
def entryUntemplates : immutable.Set[EntryUntemplate]
def mediaPathPermalink( ut : untemplate.Untemplate[?,?] ) : MediaPathPermalink
def entryInfo( template : EntryUntemplate ) : EntryInfo =
import Attribute.Key
val mbTitle = Key.`Title`.caseInsensitiveCheck(template)
val authors = Key.`Author`.caseInsensitiveCheck(template).getOrElse(defaultAuthors)
val tags = Key.`Tag`.caseInsensitiveCheck(template).getOrElse(Nil)
val pubDate = Key.`PubDate`.caseInsensitiveCheck(template).getOrElse( throw missingAttribute( template, Key.`PubDate`) )
val contentType = normalizeContentType( findContentType( template ) )
val MediaPathPermalink( mediaPathSiteRooted, permalinkSiteRooted ) = mediaPathPermalink( template )
Entry.Info(mbTitle, authors, tags, pubDate, contentType, mediaPathSiteRooted, permalinkSiteRooted)
end entryInfo
def entryInput( renderLocation : SiteLocation, resolved : EntryResolved, presentation : Entry.Presentation ) : EntryInput =
Entry.Input( this, site, renderLocation, SiteLocation(resolved.entryInfo.mediaPathSiteRooted, site), resolved.entryInfo, presentation )
def permalink( resolved : EntryResolved ) : SiteLocation = SiteLocation( resolved.entryInfo.permalinkPathSiteRooted, site )
def mediaDir( resolved : EntryResolved ) : SiteLocation = SiteLocation( resolved.entryInfo.mediaPathSiteRooted, site )
override def identifiers( resolved : EntryResolved ) : immutable.Set[String] =
super.identifiers(resolved) ++ Attribute.Key.`Anchor`.caseInsensitiveCheck(resolved.entryUntemplate)
/**
* Lays out only the entry, the fragment which will later become the main content of the page
*/
def layoutEntry( input : Layout.Input.Entry ) : String
def entrySeparator : String
def layoutPage( input : Layout.Input.Page ) : String
// see https://github.com/scala/bug/issues/12727
def previous( resolved : EntryResolved ) : Option[EntryResolved] = entriesResolved.rangeFrom(resolved).tail.headOption
def next( resolved : EntryResolved ) : Option[EntryResolved] = entriesResolved.maxBefore(resolved)
def renderSingleFragment( renderLocation : SiteLocation, resolved : EntryResolved, presentation : Entry.Presentation ) : String =
val contentType = resolved.entryInfo.contentType
val htmlifier = htmlifierForContentType(contentType).getOrElse {
throw new NoHtmlifierForContentType( s"Could not find a function to convert entries of Content-Type '${contentType}' into HTML.")
}
val ei = entryInput( renderLocation, resolved, presentation )
val result = resolved.entryUntemplate(ei)
val htmlifierOptions = Htmlifier.Options(generatorFullyQualifiedName = Some(resolved.entryUntemplate.UntemplateFullyQualifiedName))
val htmlResult = htmlifier(result.text, htmlifierOptions)
val info = resolved.entryInfo
val layoutEntryInput = Layout.Input.Entry(this, site, renderLocation, htmlResult, info, resolved, previous(resolved), next(resolved), presentation )
val hashSpecialsUnresolvedHtml = layoutEntry( layoutEntryInput )
if entryFragmentsResolveHashSpecials then
val resolveEscapes = // we only want to do this once for each piece of text
presentation match
case Entry.Presentation.Single => !entryTopLevelResolveHashSpecials
case Entry.Presentation.Multiple => !multipleTopLevelResolveHashSpecials
case Entry.Presentation.Rss => true // there is never a potential higher-level resolver for RSS fragments
val sourceId = s"entry-fragment[${presentation}](source=${resolved.entryUntemplate.UntemplateName}, endpoint=${renderLocation.siteRootedPath})"
site.htmlFragmentResolveHashSpecials(sourceId, renderLocation.siteRootedPath, hashSpecialsUnresolvedHtml, Some(resolved.entryInfo.mediaPathSiteRooted), resolveEscapes)
else
hashSpecialsUnresolvedHtml
def renderSingle( renderLocation : SiteLocation, resolved : EntryResolved ) : String =
val entry = renderSingleFragment(renderLocation, resolved, Entry.Presentation.Single)
val layoutPageInput = Layout.Input.Page(this, site, renderLocation, entry, immutable.Seq(resolved))
layoutPage( layoutPageInput )
// you can override this
def renderMultiplePrologue : String = ""
// you can override this
def renderMultipleEpilogue : String = ""
def renderMultiple( renderLocation : SiteLocation, resolveds : immutable.Seq[EntryResolved] ) : String =
// println("renderMultiple(...)")
// resolveds.foreach( r => println(s"${r.entryUntemplate} -- ${r.entryInfo.pubDate}") )
val fragmentTexts = resolveds.map(resolved => renderSingleFragment(renderLocation, resolved, Entry.Presentation.Multiple))
val unifiedFragmentTexts = fragmentTexts.mkString(entrySeparator)
val fullText = renderMultiplePrologue + unifiedFragmentTexts + renderMultipleEpilogue
val layoutPageInput = Layout.Input.Page(this, site, renderLocation, fullText, resolveds)
layoutPage( layoutPageInput )
def renderRange(renderLocation: SiteLocation, from: Instant, until: Instant): String =
val ordering = summon[Ordering[Instant]]
val rs = entriesResolved.filter(r => ordering.gteq(from, r.entryInfo.pubDate) && ordering.lt(r.entryInfo.pubDate, until))
renderMultiple(renderLocation, rs.toVector)
def renderSince(renderLocation: SiteLocation, from: Instant): String =
renderRange( renderLocation, from, Instant.now)
override def endpointBindings : immutable.Seq[ZTEndpointBinding] =
super.endpointBindings :+ ZTEndpointBinding.publicReadOnlyRss( rssFeed, zio.ZIO.attempt( feedBytes ), rssFeedIdentifiers )
© 2015 - 2025 Weber Informatics LLC | Privacy Policy