Telegram now supports Scripts
You can add scripts to your Telegram rendering so that you can programmatically add snippets, access external data, and do all kinds of other stuff as part of rendering your Telegram site.
Our first Script
Here's a pretty complex script I wrote as part of migrating a PHP site to Telegram:
package sloth
import org.hoisted.lib._
import net.liftweb._
import common._
import util._
import Helpers._
import scala.xml._
object Moose {
def renderNews(xml: NodeSeq): NodeSeq => NodeSeq = {
def order(n: Node): Int = (n \ "LinkID").headOption.flatMap(x => Helpers.asInt(x.text)) getOrElse 1
val what = (xml \ "Link").toList.sortWith((n, n2) => order(n) < order(n2))
def start = "http://www.lafayettedolphins.net/index.php?item="
def fixLink(in: Node): String = in.text match {
case s if s.startsWith(start) => "/content/" + s.substring(start.length).toLowerCase
case s if s.startsWith("http:") || s.startsWith("https:") => s
case s if !s.startsWith("/") => "/content/" + s.toLowerCase
case s => s
}
"li" #> what.map(item =>
"img [src+]" #> (((item \ "LinkType").headOption.map(_.text) match {
case Some("pdf") => "pdf_icon.png"
case _ => "external_link_icon.png"
}): String) andThen "a [href]" #> (item \ "LinkUrl").headOption.map(fixLink(_)) &
"a [target]" #> ((item \ "LinkType").headOption.map(_.text) match {
case Some("pdf") => "pdfWindow"
case Some("external") => "_blank"
case _ => "_self"
}) & "a -*" #> (item \ "LinkDisplayName").headOption.map(_.text))
}
}
object Item {
def fromXml(in: NodeSeq): List[Item] = {
((in \ "Item").toList.flatMap(fromXml(_)) :::
(in \ "SubItem").toList.flatMap(fromXml(_)) :::
(((in \ "ItemID").map(_.text).flatMap(Helpers.asInt(_)).headOption,
(in \ "ItemName").map(_.text.replace(" ", "_").toLowerCase).headOption,
(in \ "ItemDisplayName").map(_.text).headOption,
(in \ "ItemDescription").map(_.text).headOption) match {
case (Some(id), Some(name), Some(displayName), Some(description)) =>
List(Item(id, name, displayName, description, fromXml(in \ "SubItems")))
case _ => Nil
}) :::
(((in \ "SubItemID").map(_.text).flatMap(Helpers.asInt(_)).headOption,
(in \ "SubItemName").map(_.text.replace(" ", "_").toLowerCase).headOption,
(in \ "SubItemDisplayName").map(_.text).headOption,
(in \ "SubItemDescription").map(_.text).headOption) match {
case (Some(id), Some(name), Some(displayName), Some(description)) =>
List(Item(id, name, displayName, description, fromXml(in \ "SubItems")))
case _ => Nil
})).sortWith((a, b) => a.id < b.id)
}
}
case class Item(id: Int, name: String, displayName: String, description: String,
kids: List[Item]) {
def path = name match {
case "home" => "/home"
case x => "/content/" + x
}
}
class Moose extends PluginPhase1 {
def apply(in: List[ParsedFile]): List[ParsedFile] = {
val env = HoistedEnvironmentManager.value
val promo = in.filter(p => p.fileInfo.pathAndSuffix.path.startsWith("promotion_slide_show" :: Nil) &&
p.fileInfo.pathAndSuffix.suffix == Some("jpg"))
env.addSnippet {
case ("promo", "render") =>
Full("img" #> promo.map(file => "img [src]" #> file.fileInfo.pathAndSuffix.display))
}
for {
xml <- HoistedUtil.xmlForFile("school_news" :: "links.xml" :: Nil, in)
} {
env.addSnippet(
Map(("dolphins", "news") -> Full(Moose.renderNews(xml))))
}
for {
xml <- HoistedUtil.xmlForFile("navigation" :: "navigation.xml" :: Nil, in)
} {
def currentPath(i: Item): Boolean = {
CurrentFile.value match {
case null => false
case f => f.fileInfo.pathAndSuffix.path.contains(i.name)
}
}
val items = Item.fromXml(xml \ "Items")
env.addSnippet {
case ("dmenu", "short") => Full("li" #> items.map(i => (if (currentPath(i)) "li [class+]" #> "selected" else PassThru) andThen
"a *" #> i.displayName & "a [href]" #> i.path))
case ("dmenu", "bottom") => Full("li" #> items.zipWithIndex.map {
case (i, _) if i.kids.isEmpty =>
"a [class]" #> "header" & "a [href]" #> i.path & "a *" #> i.displayName
case (i, pos) =>
"a [id]" #> ("footer_" + (pos + 1)) & "a [class]" #> "header" & "a [href]" #> i.path &
"a [onMouseover]" #> ("showmenu('footer_" + (pos + 1) + "', event, linkset[" + (pos + 1) + "])") & "a [onMouseout]" #> "delayhidemenu()" &
"a *" #> i.displayName
})
case ("dmenu", "left") =>
def topItem = items.find(i => CurrentFile.value.fileInfo.pathAndSuffix.path.contains(i.name)).headOption
Full("li" #> topItem.toList.flatMap(topper => {
topper.kids.map(i => (if (currentPath(i)) "li [class+]" #> "selected" else PassThru) andThen "a *" #> i.displayName & "a [href]" #>
(if (i.name == "home") "/home" else "/content/" + topper.name + "/" + i.name))
}))
}
}
in.flatMap(pf => pf.fileInfo.pathAndSuffix.path match {
case "content" :: rest if rest.takeRight(1) != List("index") =>
val fi = pf.fileInfo
val ps = fi.pathAndSuffix
val nps = ps.copy(path = ps.path.dropRight(1) ::: List("index"))
val nfi = nps.toFileInfo(Empty)
val ret = pf.updateFileInfo(nfi)
List(pf, ret)
case _ => List(pf)
})
}
}
Let's break the script down:
All *.scala
files in the _scripts
directory or any subdirectories will be compiled.
All top level classes (not inner classes) that extend the PluginPhase1
class will be instantiated
and the apply(in: List[ParsedFile]): List[ParsedFile]
method will be called at Phase 1, which
is after the files and metadata from the base repository has been read, but before
the linked repositories have been loaded. There will be other plugin phases in the future,
but right now, we only have access to Phase 1.
The parameter is the list of all the ParseFiles
found as part of the initial scan. You can also access the current EnvironmentManager
via the HoistedEnvironmentManager
thread-local.
Find all the promotional images and create a snippet for them
The first thing our script does is to find all the jpg
files in the promotional_slide_show
directory
and create a snippet that will populate <img>
tags will be images:
val promo = in.filter(p => p.fileInfo.pathAndSuffix.path.startsWith("promotion_slide_show" :: Nil) &&
p.fileInfo.pathAndSuffix.suffix == Some("jpg"))
env.addSnippet {
case ("promo", "render") =>
Full("img" #> promo.map(file => "img [src]" #> file.fileInfo.pathAndSuffix.display))
}
We do this by filtering the incoming list of files to find the matching files. When we register a new snippet with
the EnvironmentManager
that will populate the src
attribute of the <img>
tags with the path to the file.
The env.addSnippet
method takes a PartualFunction[(String, String), Box[NodeSeq => NodeSeq]]
. The pair of String
s
is the snippet name, so in this case, the snippet would be invoked with <div data-lift="promo"><img></div>
because
the second item in the pair is render
and that translates to a blank name. If the pair were ("promo", "images")
then the invocation would be <div data-lift="promo.images"><img></div>
.
School News based on an XML file
The site that I've converted was PHP based and contained a bunch of XML
files with configuration information. The next part of the code reads the /school_news/links.xml
file and, if the file exists and can be parsed, the dolphin.news
snippet is created.
for {
xml <- HoistedUtil.xmlForFile("school_news" :: "links.xml" :: Nil, in)
} {
env.addSnippet(
Map(("dolphins", "news") -> Full(Moose.renderNews(xml))))
}
Here's how the dolphins.news
snippet works:
def renderNews(xml: NodeSeq): NodeSeq => NodeSeq = {
def order(n: Node): Int = (n \ "LinkID").headOption.flatMap(x => Helpers.asInt(x.text)) getOrElse 1
val what = (xml \ "Link").toList.sortWith((n, n2) => order(n) < order(n2))
def start = "http://www.lafayettedolphins.net/index.php?item="
def fixLink(in: Node): String = in.text match {
case s if s.startsWith(start) => "/content/" + s.substring(start.length).toLowerCase
case s if s.startsWith("http:") || s.startsWith("https:") => s
case s if !s.startsWith("/") => "/content/" + s.toLowerCase
case s => s
}
"li" #> what.map(item =>
"img [src+]" #> (((item \ "LinkType").headOption.map(_.text) match {
case Some("pdf") => "pdf_icon.png"
case _ => "external_link_icon.png"
}): String) andThen "a [href]" #> (item \ "LinkUrl").headOption.map(fixLink(_)) &
"a [target]" #> ((item \ "LinkType").headOption.map(_.text) match {
case Some("pdf") => "pdfWindow"
case Some("external") => "_blank"
case _ => "_self"
}) & "a -*" #> (item \ "LinkDisplayName").headOption.map(_.text))
}
The what
value is populated with all the links, sorted by LinkId
.
The core of the work is done in the CSS Selector Transform
that transforms the <li>
tag updating the <img>
src
attribute and setting the href
,
target
, and body of the <a>
tag.
Menus
Next, we read the /navigation/navigation.xml
file and create snippets based on
the file for the short, left, and bottom menus.
Most of the code is a re-hash of what we've seen. There's one additional piece
of code that's interesting: CurrentFile.value
. This accesses the current
file that's being rendered. This allows us to test how mark the menu path.
Creating shadow index
files
The last bit of code creates shadow index.html
files
in directories. This is due to the layout of the legacy PHP site.
The code looks like:
in.flatMap(pf => pf.fileInfo.pathAndSuffix.path match {
case "content" :: rest if rest.takeRight(1) != List("index") =>
val fi = pf.fileInfo
val ps = fi.pathAndSuffix
val nps = ps.copy(path = ps.path.dropRight(1) ::: List("index"))
val nfi = nps.toFileInfo(Empty)
val ret = pf.updateFileInfo(nfi)
List(pf, ret)
case _ => List(pf)
})
The code takes the List[ParsedFile]
and for all the files
in the /content
directory that are not named index.html
,
we create a shadow file that's got the same contents, but a different
path.
This demonstrates the transformation of the incoming corpus of documents that make up a site. The corpus can be transformed by adding files, removing files, changing file paths, and adding/removing metadata from files. You can also add synthetic files.
Conclusion
Telegram has scripting capabilities. They are rough and it's difficult to edit/debug (we're working on a live Hoisted server that will show you changes immediately on a local machine). But they offer the ability to add new snippets as well as modifying the document corpus to allow a ton of flexibility for Telegram sites.