Work on language support

This commit is contained in:
Alexander Nozik 2023-01-08 15:58:27 +03:00
parent 62da832db1
commit f43b23c84f
Signed by: altavir
GPG Key ID: B10A55DCC7B9AEBB
10 changed files with 412 additions and 193 deletions

View File

@ -1,3 +1,3 @@
kotlin.code.style=official kotlin.code.style=official
toolsVersion=0.13.1-kotlin-1.7.20 toolsVersion=0.13.3-kotlin-1.7.20

View File

@ -0,0 +1,83 @@
package space.kscience.snark.html
import kotlinx.html.body
import kotlinx.html.head
import kotlinx.html.title
import space.kscience.dataforge.data.Data
import space.kscience.dataforge.data.getItem
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.appendLeft
import kotlin.reflect.typeOf
/**
* Render (or don't) given data piece
*/
public interface DataRenderer {
context(SiteBuilder)
public operator fun invoke(name: Name, data: Data<Any>)
public companion object {
// context (SiteBuilder)
// public fun buildPageMeta(name: Name, data: Data<Any>): Laminate {
// val languages = languages.mapKeys { it.value["key"]?.string ?: it.key }
//
// // detect current language by prefix if it is not defined explicitly
// val currentLanguage: String = data.meta["language"]?.string
// ?: languages.keys.firstOrNull() { key -> name.startsWith(key.parseAsName()) } ?: defaultLanguage
//
// //
// val languageMap = Meta {
// languages.forEach { (key, meta) ->
// val languagePrefix: String = meta.string ?: meta["name"]?.string ?: return@forEach
// val targetName = name.removeHeadOrNull("")
// val targetData = this@SiteBuilder.data[targetName.parseAsName()]
// if (targetData != null) key put targetName
// }
// }
// val languageMeta = Meta {
// "language" put currentLanguage
// if (!languageMap.isEmpty()) {
// "languageMap" put languageMap
// }
// }
// return Laminate(data.meta, languageMeta, siteMeta)
// }
/**
* Automatically build a language map for a data piece with given [name] based on existence of appropriate data nodes.
*/
context(SiteBuilder)
public fun buildLanguageMeta(name: Name): Meta = Meta {
languages.forEach { (key, meta) ->
val languagePrefix = meta["prefix"].string ?: key
val nameWithLanguage: Name = if (languagePrefix.isBlank()) name else name.appendLeft(languagePrefix)
if (data.getItem(name) != null) {
key put meta.asMutableMeta().apply {
"target" put nameWithLanguage.toString()
}
}
}
}
public val DEFAULT: DataRenderer = object : DataRenderer {
context(SiteBuilder)
override fun invoke(name: Name, data: Data<Any>) {
if (data.type == typeOf<HtmlData>()) {
page(name, data.meta) {
head {
title = data.meta["title"].string ?: "Untitled page"
}
body {
@Suppress("UNCHECKED_CAST")
htmlData(data as HtmlData)
}
}
}
}
}
}
}

View File

@ -26,13 +26,23 @@ public typealias HtmlData = Data<HtmlFragment>
// Data(HtmlFragment(content), meta) // Data(HtmlFragment(content), meta)
context(WebPage) public fun FlowContent.htmlData(data: HtmlData): Unit = runBlocking(Dispatchers.IO) { context(WebPage)
public fun FlowContent.htmlData(data: HtmlData): Unit = runBlocking(Dispatchers.IO) {
with(data.await()) { consumer.renderFragment(page) } with(data.await()) { consumer.renderFragment(page) }
} }
context(SnarkContext) public val Data<*>.id: String get() = meta["id"]?.string ?: "block[${hashCode()}]" context(SnarkContext)
context(SnarkContext) public val Data<*>.language: String? get() = meta["language"].string?.lowercase() public val Data<*>.id: String
get() = meta["id"]?.string ?: "block[${hashCode()}]"
context(SnarkContext) public val Data<*>.order: Int? get() = meta["order"]?.int context(SnarkContext)
public val Data<*>.language: String?
get() = meta["language"].string?.lowercase()
context(SnarkContext) public val Data<*>.published: Boolean get() = meta["published"].string != "false" context(SnarkContext)
public val Data<*>.order: Int?
get() = meta["order"]?.int
context(SnarkContext)
public val Data<*>.published: Boolean
get() = meta["published"].string != "false"

View File

@ -4,11 +4,18 @@ import kotlinx.html.HTML
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.context.ContextAware
import space.kscience.dataforge.data.DataTree import space.kscience.dataforge.data.DataTree
import space.kscience.dataforge.data.DataTreeItem
import space.kscience.dataforge.data.getItem
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.getIndexed
import space.kscience.dataforge.meta.string
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.NameToken import space.kscience.dataforge.names.NameToken
import space.kscience.dataforge.names.asName
import space.kscience.dataforge.names.parseAsName import space.kscience.dataforge.names.parseAsName
import space.kscience.snark.SnarkContext import space.kscience.snark.SnarkContext
import space.kscience.snark.html.SiteLayout.Companion.LAYOUT_KEY
import java.nio.file.Path import java.nio.file.Path
@ -17,6 +24,11 @@ import java.nio.file.Path
*/ */
public interface SiteBuilder : ContextAware, SnarkContext { public interface SiteBuilder : ContextAware, SnarkContext {
/**
* Route name of this [SiteBuilder] relative to the site root
*/
public val route: Name
/** /**
* Data used for site construction. The type of the data is not limited * Data used for site construction. The type of the data is not limited
*/ */
@ -51,8 +63,10 @@ public interface SiteBuilder : ContextAware, SnarkContext {
/** /**
* Create a single page at given [route]. If route is empty, create an index page at current route. * Create a single page at given [route]. If route is empty, create an index page at current route.
*
* @param pageMeta additional page meta. [WebPage] will use both it and [siteMeta]
*/ */
public fun page(route: Name = Name.EMPTY, content: context(WebPage, HTML) () -> Unit) public fun page(route: Name = Name.EMPTY, pageMeta: Meta = Meta.EMPTY, content: context(WebPage, HTML) () -> Unit)
/** /**
* Create a route with optional data tree override. For example one could use a subtree of the initial tree. * Create a route with optional data tree override. For example one could use a subtree of the initial tree.
@ -61,36 +75,63 @@ public interface SiteBuilder : ContextAware, SnarkContext {
public fun route( public fun route(
routeName: Name, routeName: Name,
dataOverride: DataTree<*>? = null, dataOverride: DataTree<*>? = null,
metaOverride: Meta? = null, routeMeta: Meta = Meta.EMPTY,
setAsRoot: Boolean = false,
): SiteBuilder ): SiteBuilder
/**
* Creates a route and sets it as site base url
*/
public fun site(
routeName: Name,
dataOverride: DataTree<*>? = null,
routeMeta: Meta = Meta.EMPTY,
): SiteBuilder
public companion object { public companion object {
public val INDEX_PAGE_TOKEN: NameToken = NameToken("index") public val INDEX_PAGE_TOKEN: NameToken = NameToken("index")
public val UP_PAGE_TOKEN: NameToken = NameToken("..") public val UP_PAGE_TOKEN: NameToken = NameToken("..")
} }
} }
context(SiteBuilder) public val siteBuilder: SiteBuilder get() = this@SiteBuilder context(SiteBuilder)
public val siteBuilder: SiteBuilder
get() = this@SiteBuilder
public inline fun SiteBuilder.route( public inline fun SiteBuilder.route(
route: Name, route: Name,
dataOverride: DataTree<*>? = null, dataOverride: DataTree<*>? = null,
metaOverride: Meta? = null, routeMeta: Meta = Meta.EMPTY,
setAsRoot: Boolean = false,
block: SiteBuilder.() -> Unit, block: SiteBuilder.() -> Unit,
) { ) {
route(route, dataOverride, metaOverride, setAsRoot).apply(block) route(route, dataOverride, routeMeta).apply(block)
} }
public inline fun SiteBuilder.route( public inline fun SiteBuilder.route(
route: String, route: String,
dataOverride: DataTree<*>? = null, dataOverride: DataTree<*>? = null,
metaOverride: Meta? = null, routeMeta: Meta = Meta.EMPTY,
setAsRoot: Boolean = false,
block: SiteBuilder.() -> Unit, block: SiteBuilder.() -> Unit,
) { ) {
route(route.parseAsName(), dataOverride, metaOverride, setAsRoot).apply(block) route(route.parseAsName(), dataOverride, routeMeta).apply(block)
}
public inline fun SiteBuilder.site(
route: Name,
dataOverride: DataTree<*>? = null,
routeMeta: Meta = Meta.EMPTY,
block: SiteBuilder.() -> Unit,
) {
site(route, dataOverride, routeMeta).apply(block)
}
public inline fun SiteBuilder.site(
route: String,
dataOverride: DataTree<*>? = null,
routeMeta: Meta = Meta.EMPTY,
block: SiteBuilder.() -> Unit,
) {
site(route.parseAsName(), dataOverride, routeMeta).apply(block)
} }
@ -107,3 +148,98 @@ public inline fun SiteBuilder.route(
// withData(mountedData).block() // withData(mountedData).block()
// } // }
//} //}
internal fun SiteBuilder.assetsFrom(rootMeta: Meta) {
rootMeta.getIndexed("resource".asName()).forEach { (_, meta) ->
val path by meta.string()
val remotePath by meta.string()
path?.let { resourcePath ->
//If remote path provided, use a single resource
remotePath?.let {
resourceFile(it, resourcePath)
return@forEach
}
//otherwise use package resources
resourceDirectory(resourcePath)
}
}
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
val remotePath by meta.string { error("File remote path is not provided") }
val path by meta.string { error("File path is not provided") }
file(Path.of(path), remotePath)
}
rootMeta.getIndexed("directory".asName()).forEach { (_, meta) ->
val path by meta.string { error("Directory path is not provided") }
file(Path.of(path), "")
}
}
/**
* Recursively renders the data items in [data]. If [LAYOUT_KEY] is defined in an item, use it to load
* layout from the context, otherwise render children nodes as name segments and individual data items using [dataRenderer].
*/
public fun SiteBuilder.pages(
data: DataTreeItem<*>,
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
) {
val layoutMeta = data.meta[LAYOUT_KEY]
if (layoutMeta != null) {
//use layout if it is defined
snark.siteLayout(layoutMeta).render(data)
} else {
when (data) {
is DataTreeItem.Node -> {
data.tree.items.forEach { (token, item) ->
//Don't apply index token
if (token == SiteLayout.INDEX_PAGE_TOKEN) {
pages(item, dataRenderer)
} else if (item is DataTreeItem.Leaf) {
dataRenderer(token.asName(), item.data)
} else {
route(token.asName()) {
pages(item, dataRenderer)
}
}
}
}
is DataTreeItem.Leaf -> {
dataRenderer(Name.EMPTY, data.data)
}
}
data.meta[SiteLayout.ASSETS_KEY]?.let {
assetsFrom(it)
}
}
//TODO watch for changes
}
/**
* Render all pages in a node with given name
*/
public fun SiteBuilder.pages(
dataPath: Name,
remotePath: Name = dataPath,
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
) {
val item = data.getItem(dataPath) ?: error("No data found by name $dataPath")
route(remotePath) {
pages(item, dataRenderer)
}
}
public fun SiteBuilder.pages(
dataPath: String,
remotePath: Name = dataPath.parseAsName(),
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
) {
pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
}

View File

@ -1,121 +1,8 @@
package space.kscience.snark.html package space.kscience.snark.html
import kotlinx.html.body
import kotlinx.html.head
import kotlinx.html.title
import space.kscience.dataforge.data.Data
import space.kscience.dataforge.data.DataTreeItem import space.kscience.dataforge.data.DataTreeItem
import space.kscience.dataforge.data.getItem
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.getIndexed
import space.kscience.dataforge.meta.string
import space.kscience.dataforge.misc.Type import space.kscience.dataforge.misc.Type
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.NameToken import space.kscience.dataforge.names.NameToken
import space.kscience.dataforge.names.asName
import space.kscience.dataforge.names.parseAsName
import space.kscience.snark.html.SiteLayout.Companion.ASSETS_KEY
import space.kscience.snark.html.SiteLayout.Companion.INDEX_PAGE_TOKEN
import space.kscience.snark.html.SiteLayout.Companion.LAYOUT_KEY
import java.nio.file.Path
import kotlin.reflect.typeOf
internal fun SiteBuilder.assetsFrom(rootMeta: Meta) {
rootMeta.getIndexed("resource".asName()).forEach { (_, meta) ->
val path by meta.string()
val remotePath by meta.string()
path?.let { resourcePath ->
//If remote path provided, use a single resource
remotePath?.let {
resourceFile(it, resourcePath)
return@forEach
}
//otherwise use package resources
resourceDirectory(resourcePath)
}
}
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
val remotePath by meta.string { error("File remote path is not provided") }
val path by meta.string { error("File path is not provided") }
file(Path.of(path), remotePath)
}
rootMeta.getIndexed("directory".asName()).forEach { (_, meta) ->
val path by meta.string { error("Directory path is not provided") }
file(Path.of(path), "")
}
}
/**
* Render (or don't) given data piece
*/
public typealias DataRenderer = SiteBuilder.(name: Name, data: Data<Any>) -> Unit
/**
* Recursively renders the data items in [data]. If [LAYOUT_KEY] is defined in an item, use it to load
* layout from the context, otherwise render children nodes as name segments and individual data items using [dataRenderer].
*/
public fun SiteBuilder.pages(
data: DataTreeItem<*>,
dataRenderer: DataRenderer = SiteLayout.defaultDataRenderer,
) {
val layoutMeta = data.meta[LAYOUT_KEY]
if (layoutMeta != null) {
//use layout if it is defined
snark.siteLayout(layoutMeta).render(data)
} else {
when (data) {
is DataTreeItem.Node -> {
data.tree.items.forEach { (token, item) ->
//Don't apply index token
if (token == INDEX_PAGE_TOKEN) {
pages(item, dataRenderer)
} else if (item is DataTreeItem.Leaf) {
dataRenderer(this, token.asName(), item.data)
} else {
route(token.asName()) {
pages(item, dataRenderer)
}
}
}
}
is DataTreeItem.Leaf -> {
dataRenderer.invoke(this, Name.EMPTY, data.data)
}
}
data.meta[ASSETS_KEY]?.let {
assetsFrom(it)
}
}
//TODO watch for changes
}
/**
* Render all pages in a node with given name
*/
public fun SiteBuilder.pages(
dataPath: Name,
remotePath: Name = dataPath,
dataRenderer: DataRenderer = SiteLayout.defaultDataRenderer,
) {
val item = data.getItem(dataPath) ?: error("No data found by name $dataPath")
route(remotePath) {
pages(item, dataRenderer)
}
}
public fun SiteBuilder.pages(
dataPath: String,
remotePath: Name = dataPath.parseAsName(),
dataRenderer: DataRenderer = SiteLayout.defaultDataRenderer,
) {
pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
}
/** /**
* An abstraction to render singular data or a data tree. * An abstraction to render singular data or a data tree.
@ -123,32 +10,20 @@ public fun SiteBuilder.pages(
@Type(SiteLayout.TYPE) @Type(SiteLayout.TYPE)
public fun interface SiteLayout { public fun interface SiteLayout {
context(SiteBuilder) public fun render(item: DataTreeItem<*>) context(SiteBuilder)
public fun render(item: DataTreeItem<*>)
public companion object { public companion object {
public const val TYPE: String = "snark.layout" public const val TYPE: String = "snark.layout"
public const val LAYOUT_KEY: String = "layout" public const val LAYOUT_KEY: String = "layout"
public const val ASSETS_KEY: String = "assets" public const val ASSETS_KEY: String = "assets"
public val INDEX_PAGE_TOKEN: NameToken = NameToken("index") public val INDEX_PAGE_TOKEN: NameToken = NameToken("index")
}
}
public val defaultDataRenderer: SiteBuilder.(name: Name, data: Data<*>) -> Unit = { name: Name, data: Data<*> ->
if (data.type == typeOf<HtmlData>()) {
page(name) {
head {
title = data.meta["title"].string ?: "Untitled page"
}
body {
@Suppress("UNCHECKED_CAST")
htmlData(data as HtmlData)
}
}
}
}
}
}
/** /**
* The default [SiteLayout]. It renders all [HtmlData] pages as t with simple headers via [SiteLayout.defaultDataRenderer] * The default [SiteLayout]. It renders all [HtmlData] pages with simple headers via [SiteLayout.defaultDataRenderer]
*/ */
public object DefaultSiteLayout : SiteLayout { public object DefaultSiteLayout : SiteLayout {
context(SiteBuilder) override fun render(item: DataTreeItem<*>) { context(SiteBuilder) override fun render(item: DataTreeItem<*>) {

View File

@ -4,10 +4,11 @@ import kotlinx.html.HTML
import kotlinx.html.html import kotlinx.html.html
import kotlinx.html.stream.createHTML import kotlinx.html.stream.createHTML
import space.kscience.dataforge.data.DataTree import space.kscience.dataforge.data.DataTree
import space.kscience.dataforge.meta.Laminate
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.withDefault
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.isEmpty import space.kscience.dataforge.names.isEmpty
import space.kscience.dataforge.names.plus
import space.kscience.snark.SnarkEnvironment import space.kscience.snark.SnarkEnvironment
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@ -24,6 +25,7 @@ internal class StaticSiteBuilder(
override val data: DataTree<*>, override val data: DataTree<*>,
override val siteMeta: Meta, override val siteMeta: Meta,
private val baseUrl: String, private val baseUrl: String,
override val route: Name,
private val outputPath: Path, private val outputPath: Path,
) : SiteBuilder { ) : SiteBuilder {
private fun Path.copyRecursively(target: Path) { private fun Path.copyRecursively(target: Path) {
@ -68,25 +70,25 @@ internal class StaticSiteBuilder(
"${baseUrl.removeSuffix("/")}/$ref" "${baseUrl.removeSuffix("/")}/$ref"
} }
inner class StaticWebPage : WebPage { inner class StaticWebPage(override val pageMeta: Meta) : WebPage {
override val data: DataTree<*> get() = this@StaticSiteBuilder.data override val data: DataTree<*> get() = this@StaticSiteBuilder.data
override val pageMeta: Meta get() = this@StaticSiteBuilder.siteMeta
override val snark: SnarkHtmlPlugin get() = this@StaticSiteBuilder.snark override val snark: SnarkHtmlPlugin get() = this@StaticSiteBuilder.snark
override fun resolveRef(ref: String): String = resolveRef(baseUrl, ref) override fun resolveRef(ref: String): String = resolveRef(baseUrl, ref)
override fun resolvePageRef(pageName: Name): String = resolveRef( override fun resolvePageRef(pageName: Name, relative: Boolean): String = resolveRef(
pageName.toWebPath() + ".html" (if (relative) route + pageName else pageName).toWebPath() + ".html"
) )
} }
override fun page(route: Name, content: context(WebPage, HTML) () -> Unit) { override fun page(route: Name, pageMeta: Meta, content: context(WebPage, HTML) () -> Unit) {
val htmlBuilder = createHTML() val htmlBuilder = createHTML()
htmlBuilder.html { htmlBuilder.html {
content(StaticWebPage(), this) content(StaticWebPage(pageMeta), this)
} }
val newPath = if (route.isEmpty()) { val newPath = if (route.isEmpty()) {
@ -102,23 +104,32 @@ internal class StaticSiteBuilder(
override fun route( override fun route(
routeName: Name, routeName: Name,
dataOverride: DataTree<*>?, dataOverride: DataTree<*>?,
metaOverride: Meta?, routeMeta: Meta,
setAsRoot: Boolean,
): SiteBuilder = StaticSiteBuilder( ): SiteBuilder = StaticSiteBuilder(
snark = snark, snark = snark,
data = dataOverride ?: data, data = dataOverride ?: data,
siteMeta = metaOverride?.withDefault(siteMeta) ?: siteMeta, siteMeta = Laminate(routeMeta, siteMeta),
baseUrl = if (setAsRoot) { baseUrl = baseUrl,
resolveRef(baseUrl, routeName.toWebPath()) route = route + routeName,
} else { outputPath = outputPath.resolve(routeName.toWebPath())
baseUrl )
},
override fun site(
routeName: Name,
dataOverride: DataTree<*>?,
routeMeta: Meta,
): SiteBuilder = StaticSiteBuilder(
snark = snark,
data = dataOverride ?: data,
siteMeta = Laminate(routeMeta, siteMeta),
baseUrl = resolveRef(baseUrl, routeName.toWebPath()),
route = Name.EMPTY,
outputPath = outputPath.resolve(routeName.toWebPath()) outputPath = outputPath.resolve(routeName.toWebPath())
) )
} }
/** /**
* Create a static site using given [data] in provided [outputPath]. * Create a static site using given [SnarkEnvironment] in provided [outputPath].
* Use [siteUrl] as a base for all resolved URLs. By default, use [outputPath] absolute path as a base. * Use [siteUrl] as a base for all resolved URLs. By default, use [outputPath] absolute path as a base.
* *
*/ */
@ -131,5 +142,5 @@ public fun SnarkEnvironment.static(
callsInPlace(block, InvocationKind.EXACTLY_ONCE) callsInPlace(block, InvocationKind.EXACTLY_ONCE)
} }
val plugin = buildHtmlPlugin() val plugin = buildHtmlPlugin()
StaticSiteBuilder(plugin, data, meta, siteUrl, outputPath).block() StaticSiteBuilder(plugin, data, meta, siteUrl, Name.EMPTY, outputPath).block()
} }

View File

@ -11,7 +11,8 @@ import space.kscience.dataforge.names.parseAsName
*/ */
@Type(TextProcessor.TYPE) @Type(TextProcessor.TYPE)
public fun interface TextProcessor { public fun interface TextProcessor {
context(WebPage) public fun process(text: String): String context(WebPage)
public fun process(text: String): String
public companion object { public companion object {
public const val TYPE: String = "snark.textTransformation" public const val TYPE: String = "snark.textTransformation"
@ -31,23 +32,27 @@ public object BasicTextProcessor : TextProcessor {
private val regex = """\$\{([\w.]*)(?>\("(.*)"\))?}""".toRegex() private val regex = """\$\{([\w.]*)(?>\("(.*)"\))?}""".toRegex()
context(WebPage) override fun process(text: String): String = text.replace(regex) { match -> context(WebPage)
override fun process(text: String): String = text.replace(regex) { match ->
when (match.groups[1]!!.value) { when (match.groups[1]!!.value) {
"homeRef" -> homeRef "homeRef" -> homeRef
"resolveRef" -> { "resolveRef" -> {
val refString = match.groups[2]?.value ?: error("resolveRef requires a string (quoted) argument") val refString = match.groups[2]?.value ?: error("resolveRef requires a string (quoted) argument")
resolveRef(refString) resolveRef(refString)
} }
"resolvePageRef" -> { "resolvePageRef" -> {
val refString = match.groups[2]?.value val refString = match.groups[2]?.value
?: error("resolvePageRef requires a string (quoted) argument") ?: error("resolvePageRef requires a string (quoted) argument")
resolvePageRef(refString) localisedPageRef(refString.parseAsName())
} }
"pageMeta.get" -> { "pageMeta.get" -> {
val nameString = match.groups[2]?.value val nameString = match.groups[2]?.value
?: error("resolvePageRef requires a string (quoted) argument") ?: error("resolvePageRef requires a string (quoted) argument")
pageMeta[nameString.parseAsName()].string ?: "@null" pageMeta[nameString.parseAsName()].string ?: "@null"
} }
else -> match.value else -> match.value
} }
} }

View File

@ -9,7 +9,8 @@ import space.kscience.dataforge.meta.string
import space.kscience.dataforge.names.* import space.kscience.dataforge.names.*
import space.kscience.snark.SnarkContext import space.kscience.snark.SnarkContext
context(SnarkContext) public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") { context(SnarkContext)
public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") {
if (it.hasIndex()) { if (it.hasIndex()) {
"${it.body}[${it.index}]" "${it.body}[${it.index}]"
} else { } else {
@ -17,6 +18,9 @@ context(SnarkContext) public fun Name.toWebPath(): String = tokens.joinToString(
} }
} }
/**
* A context for building a single page
*/
public interface WebPage : ContextAware, SnarkContext { public interface WebPage : ContextAware, SnarkContext {
public val snark: SnarkHtmlPlugin public val snark: SnarkHtmlPlugin
@ -25,14 +29,28 @@ public interface WebPage : ContextAware, SnarkContext {
public val data: DataTree<*> public val data: DataTree<*>
/**
* A metadata for a page. It should include site metadata
*/
public val pageMeta: Meta public val pageMeta: Meta
/**
* Resolve absolute url for given [ref]
*
*/
public fun resolveRef(ref: String): String public fun resolveRef(ref: String): String
public fun resolvePageRef(pageName: Name): String /**
* Resolve absolute url for a page with given [pageName].
*
* @param relative if true, add [SiteBuilder] route to the absolute page name
*/
public fun resolvePageRef(pageName: Name, relative: Boolean = false): String
} }
context(WebPage) public val page: WebPage get() = this@WebPage context(WebPage)
public val page: WebPage
get() = this@WebPage
public fun WebPage.resolvePageRef(pageName: String): String = resolvePageRef(pageName.parseAsName()) public fun WebPage.resolvePageRef(pageName: String): String = resolvePageRef(pageName.parseAsName())
@ -41,7 +59,8 @@ public val WebPage.homeRef: String get() = resolvePageRef(SiteBuilder.INDEX_PAGE
/** /**
* Resolve a Html builder by its full name * Resolve a Html builder by its full name
*/ */
context(SnarkContext) public fun DataTree<*>.resolveHtml(name: Name): HtmlData? { context(SnarkContext)
public fun DataTree<*>.resolveHtml(name: Name): HtmlData? {
val resolved = (getByType<HtmlFragment>(name) ?: getByType<HtmlFragment>(name + SiteBuilder.INDEX_PAGE_TOKEN)) val resolved = (getByType<HtmlFragment>(name) ?: getByType<HtmlFragment>(name + SiteBuilder.INDEX_PAGE_TOKEN))
return resolved?.takeIf { return resolved?.takeIf {
@ -52,7 +71,8 @@ context(SnarkContext) public fun DataTree<*>.resolveHtml(name: Name): HtmlData?
/** /**
* Find all Html blocks using given name/meta filter * Find all Html blocks using given name/meta filter
*/ */
context(SnarkContext) public fun DataTree<*>.resolveAllHtml(predicate: (name: Name, meta: Meta) -> Boolean): Map<Name, HtmlData> = context(SnarkContext)
public fun DataTree<*>.resolveAllHtml(predicate: (name: Name, meta: Meta) -> Boolean): Map<Name, HtmlData> =
filterByType<HtmlFragment> { name, meta -> filterByType<HtmlFragment> { name, meta ->
predicate(name, meta) predicate(name, meta)
&& meta["published"].string != "false" && meta["published"].string != "false"
@ -60,7 +80,8 @@ context(SnarkContext) public fun DataTree<*>.resolveAllHtml(predicate: (name: Na
}.asSequence().associate { it.name to it.data } }.asSequence().associate { it.name to it.data }
context(SnarkContext) public fun DataTree<*>.findByContentType( context(SnarkContext)
public fun DataTree<*>.findByContentType(
contentType: String, contentType: String,
baseName: Name = Name.EMPTY, baseName: Name = Name.EMPTY,
): Map<Name, Data<HtmlFragment>> = resolveAllHtml { name, meta -> ): Map<Name, Data<HtmlFragment>> = resolveAllHtml { name, meta ->

View File

@ -0,0 +1,59 @@
package space.kscience.snark.html
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.string
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.parseAsName
import space.kscience.dataforge.names.plus
public val SiteBuilder.languages: Map<String, Meta>
get() = siteMeta["site.languages"]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap()
public val SiteBuilder.language: String
get() = siteMeta["site.language"].string ?: "en"
public fun SiteBuilder.withLanguages(languageMap: Map<String, Meta>, block: SiteBuilder.(language: String) -> Unit) {
languageMap.forEach { (languageKey, languageMeta) ->
val prefix = languageMeta["prefix"].string ?: languageKey
val routeMeta = Meta {
"site.language" put languageKey
"site.languages" put Meta {
languageMap.forEach {
it.key put it.value
}
}
}
route(prefix, routeMeta = routeMeta) {
block(languageKey)
}
}
}
public fun SiteBuilder.withLanguages(
vararg language: Pair<String, String>,
block: SiteBuilder.(language: String) -> Unit,
) {
val languageMap = language.associate {
it.first to Meta {
"prefix" put it.second
}
}
withLanguages(languageMap, block)
}
/**
* The language key of this page
*/
public val WebPage.language: String get() = pageMeta["language"]?.string ?: pageMeta["site.language"]?.string ?: "en"
/**
* Mapping of language keys to other language versions of this page
*/
public val WebPage.languages: Map<String, Meta>
get() = pageMeta["languages"]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap()
public fun WebPage.localisedPageRef(pageName: Name, relative: Boolean = false): String {
val prefix = languages[language]?.get("prefix")?.string?.parseAsName() ?: Name.EMPTY
return resolvePageRef(prefix + pageName, relative)
}

View File

@ -16,11 +16,12 @@ import kotlinx.html.CommonAttributeGroupFacade
import kotlinx.html.HTML import kotlinx.html.HTML
import kotlinx.html.style import kotlinx.html.style
import space.kscience.dataforge.data.DataTree import space.kscience.dataforge.data.DataTree
import space.kscience.dataforge.meta.Laminate
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.withDefault
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.cutLast import space.kscience.dataforge.names.cutLast
import space.kscience.dataforge.names.endsWith import space.kscience.dataforge.names.endsWith
import space.kscience.dataforge.names.plus
import space.kscience.snark.SnarkEnvironment import space.kscience.snark.SnarkEnvironment
import space.kscience.snark.html.* import space.kscience.snark.html.*
import java.nio.file.Path import java.nio.file.Path
@ -37,6 +38,7 @@ public class KtorSiteBuilder(
override val data: DataTree<*>, override val data: DataTree<*>,
override val siteMeta: Meta, override val siteMeta: Meta,
private val baseUrl: String, private val baseUrl: String,
override val route: Name,
private val ktorRoute: Route, private val ktorRoute: Route,
) : SiteBuilder { ) : SiteBuilder {
@ -64,21 +66,27 @@ public class KtorSiteBuilder(
private inner class KtorWebPage( private inner class KtorWebPage(
val pageBaseUrl: String, val pageBaseUrl: String,
override val pageMeta: Meta = this@KtorSiteBuilder.siteMeta, override val pageMeta: Meta,
) : WebPage { ) : WebPage {
override val snark: SnarkHtmlPlugin get() = this@KtorSiteBuilder.snark override val snark: SnarkHtmlPlugin get() = this@KtorSiteBuilder.snark
override val data: DataTree<*> get() = this@KtorSiteBuilder.data override val data: DataTree<*> get() = this@KtorSiteBuilder.data
override fun resolveRef(ref: String): String = resolveRef(pageBaseUrl, ref) override fun resolveRef(ref: String): String = resolveRef(pageBaseUrl, ref)
override fun resolvePageRef(pageName: Name): String = if (pageName.endsWith(SiteBuilder.INDEX_PAGE_TOKEN)) { override fun resolvePageRef(
resolveRef(pageName.cutLast().toWebPath()) pageName: Name,
relative: Boolean,
): String {
val fullPageName = if(relative) route + pageName else pageName
return if (fullPageName.endsWith(SiteBuilder.INDEX_PAGE_TOKEN)) {
resolveRef(fullPageName.cutLast().toWebPath())
} else { } else {
resolveRef(pageName.toWebPath()) resolveRef(fullPageName.toWebPath())
}
} }
} }
override fun page(route: Name, content: context(WebPage, HTML)() -> Unit) { override fun page(route: Name, pageMeta: Meta, content: context(WebPage, HTML)() -> Unit) {
ktorRoute.get(route.toWebPath()) { ktorRoute.get(route.toWebPath()) {
call.respondHtml { call.respondHtml {
val request = call.request val request = call.request
@ -89,7 +97,7 @@ public class KtorSiteBuilder(
port = request.origin.port port = request.origin.port
} }
val pageBuilder = KtorWebPage(url.buildString()) val pageBuilder = KtorWebPage(url.buildString(), Laminate(pageMeta, siteMeta))
content(pageBuilder, this) content(pageBuilder, this)
} }
} }
@ -98,17 +106,26 @@ public class KtorSiteBuilder(
override fun route( override fun route(
routeName: Name, routeName: Name,
dataOverride: DataTree<*>?, dataOverride: DataTree<*>?,
metaOverride: Meta?, routeMeta: Meta,
setAsRoot: Boolean,
): SiteBuilder = KtorSiteBuilder( ): SiteBuilder = KtorSiteBuilder(
snark = snark, snark = snark,
data = dataOverride ?: data, data = dataOverride ?: data,
siteMeta = metaOverride?.withDefault(siteMeta) ?: siteMeta, siteMeta = Laminate(routeMeta, siteMeta),
baseUrl = if (setAsRoot) { baseUrl = baseUrl,
resolveRef(baseUrl, routeName.toWebPath()) route = this.route + routeName,
} else { ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath())
baseUrl )
},
override fun site(
routeName: Name,
dataOverride: DataTree<*>?,
routeMeta: Meta,
): SiteBuilder = KtorSiteBuilder(
snark = snark,
data = dataOverride ?: data,
siteMeta = Laminate(routeMeta, siteMeta),
baseUrl = resolveRef(baseUrl, routeName.toWebPath()),
route = Name.EMPTY,
ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath()) ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath())
) )
@ -122,17 +139,19 @@ public class KtorSiteBuilder(
} }
} }
context(Route, SnarkEnvironment) private fun siteInRoute( context(Route, SnarkEnvironment)
private fun siteInRoute(
baseUrl: String = "", baseUrl: String = "",
block: KtorSiteBuilder.() -> Unit, block: KtorSiteBuilder.() -> Unit,
) { ) {
contract { contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE) callsInPlace(block, InvocationKind.EXACTLY_ONCE)
} }
block(KtorSiteBuilder(buildHtmlPlugin(), data, meta, baseUrl, this@Route)) block(KtorSiteBuilder(buildHtmlPlugin(), data, meta, baseUrl, route = Name.EMPTY, this@Route))
} }
context(Application) public fun SnarkEnvironment.site( context(Application)
public fun SnarkEnvironment.site(
baseUrl: String = "", baseUrl: String = "",
block: KtorSiteBuilder.() -> Unit, block: KtorSiteBuilder.() -> Unit,
) { ) {