diff --git a/gradle.properties b/gradle.properties index 2dfbcb3..24f5db5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ kotlin.code.style=official -toolsVersion=0.13.1-kotlin-1.7.20 \ No newline at end of file +toolsVersion=0.13.3-kotlin-1.7.20 \ No newline at end of file diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/DataRenderer.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/DataRenderer.kt new file mode 100644 index 0000000..cd1a00f --- /dev/null +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/DataRenderer.kt @@ -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) + + public companion object { + +// context (SiteBuilder) +// public fun buildPageMeta(name: Name, data: Data): 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) { + if (data.type == typeOf()) { + page(name, data.meta) { + head { + title = data.meta["title"].string ?: "Untitled page" + } + body { + @Suppress("UNCHECKED_CAST") + htmlData(data as HtmlData) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt index 95dbbaa..6fe0bcb 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt @@ -26,13 +26,23 @@ public typealias HtmlData = Data // 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) } } -context(SnarkContext) public val Data<*>.id: String get() = meta["id"]?.string ?: "block[${hashCode()}]" -context(SnarkContext) public val Data<*>.language: String? get() = meta["language"].string?.lowercase() +context(SnarkContext) +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" diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt index 9adfc1a..8fc352d 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt @@ -4,11 +4,18 @@ import kotlinx.html.HTML import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.ContextAware 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.get +import space.kscience.dataforge.meta.getIndexed +import space.kscience.dataforge.meta.string import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.NameToken +import space.kscience.dataforge.names.asName import space.kscience.dataforge.names.parseAsName import space.kscience.snark.SnarkContext +import space.kscience.snark.html.SiteLayout.Companion.LAYOUT_KEY import java.nio.file.Path @@ -17,6 +24,11 @@ import java.nio.file.Path */ 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 */ @@ -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. + * + * @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. @@ -61,36 +75,63 @@ public interface SiteBuilder : ContextAware, SnarkContext { public fun route( routeName: Name, dataOverride: DataTree<*>? = null, - metaOverride: Meta? = null, - setAsRoot: Boolean = false, + routeMeta: Meta = Meta.EMPTY, ): 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 val INDEX_PAGE_TOKEN: NameToken = NameToken("index") 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( route: Name, dataOverride: DataTree<*>? = null, - metaOverride: Meta? = null, - setAsRoot: Boolean = false, + routeMeta: Meta = Meta.EMPTY, block: SiteBuilder.() -> Unit, ) { - route(route, dataOverride, metaOverride, setAsRoot).apply(block) + route(route, dataOverride, routeMeta).apply(block) } public inline fun SiteBuilder.route( route: String, dataOverride: DataTree<*>? = null, - metaOverride: Meta? = null, - setAsRoot: Boolean = false, + routeMeta: Meta = Meta.EMPTY, 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) } @@ -106,4 +147,99 @@ public inline fun SiteBuilder.route( // route(route) { // withData(mountedData).block() // } -//} \ No newline at end of file +//} + + + +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) +} \ No newline at end of file diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt index 34e6143..ed8712b 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt @@ -1,121 +1,8 @@ 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.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.names.Name 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) -> 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. @@ -123,32 +10,20 @@ public fun SiteBuilder.pages( @Type(SiteLayout.TYPE) public fun interface SiteLayout { - context(SiteBuilder) public fun render(item: DataTreeItem<*>) + context(SiteBuilder) + public fun render(item: DataTreeItem<*>) public companion object { public const val TYPE: String = "snark.layout" public const val LAYOUT_KEY: String = "layout" public const val ASSETS_KEY: String = "assets" 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()) { - 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 { context(SiteBuilder) override fun render(item: DataTreeItem<*>) { diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt index 42ca85a..b998d5f 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt @@ -4,10 +4,11 @@ import kotlinx.html.HTML import kotlinx.html.html import kotlinx.html.stream.createHTML import space.kscience.dataforge.data.DataTree +import space.kscience.dataforge.meta.Laminate import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.withDefault import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.isEmpty +import space.kscience.dataforge.names.plus import space.kscience.snark.SnarkEnvironment import java.nio.file.Files import java.nio.file.Path @@ -24,6 +25,7 @@ internal class StaticSiteBuilder( override val data: DataTree<*>, override val siteMeta: Meta, private val baseUrl: String, + override val route: Name, private val outputPath: Path, ) : SiteBuilder { private fun Path.copyRecursively(target: Path) { @@ -38,10 +40,10 @@ internal class StaticSiteBuilder( override fun file(file: Path, remotePath: String) { val targetPath = outputPath.resolve(remotePath) - if(file.isDirectory()){ + if (file.isDirectory()) { targetPath.parent.createDirectories() file.copyRecursively(targetPath) - } else if(remotePath.isBlank()) { + } else if (remotePath.isBlank()) { error("Can't mount file to an empty route") } else { targetPath.parent.createDirectories() @@ -68,25 +70,25 @@ internal class StaticSiteBuilder( "${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 pageMeta: Meta get() = this@StaticSiteBuilder.siteMeta + override val snark: SnarkHtmlPlugin get() = this@StaticSiteBuilder.snark override fun resolveRef(ref: String): String = resolveRef(baseUrl, ref) - override fun resolvePageRef(pageName: Name): String = resolveRef( - pageName.toWebPath() + ".html" + override fun resolvePageRef(pageName: Name, relative: Boolean): String = resolveRef( + (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() htmlBuilder.html { - content(StaticWebPage(), this) + content(StaticWebPage(pageMeta), this) } val newPath = if (route.isEmpty()) { @@ -102,23 +104,32 @@ internal class StaticSiteBuilder( override fun route( routeName: Name, dataOverride: DataTree<*>?, - metaOverride: Meta?, - setAsRoot: Boolean, + routeMeta: Meta, ): SiteBuilder = StaticSiteBuilder( snark = snark, data = dataOverride ?: data, - siteMeta = metaOverride?.withDefault(siteMeta) ?: siteMeta, - baseUrl = if (setAsRoot) { - resolveRef(baseUrl, routeName.toWebPath()) - } else { - baseUrl - }, + siteMeta = Laminate(routeMeta, siteMeta), + baseUrl = baseUrl, + route = route + routeName, + outputPath = outputPath.resolve(routeName.toWebPath()) + ) + + 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()) ) } /** - * 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. * */ @@ -131,5 +142,5 @@ public fun SnarkEnvironment.static( callsInPlace(block, InvocationKind.EXACTLY_ONCE) } val plugin = buildHtmlPlugin() - StaticSiteBuilder(plugin, data, meta, siteUrl, outputPath).block() + StaticSiteBuilder(plugin, data, meta, siteUrl, Name.EMPTY, outputPath).block() } \ No newline at end of file diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/TextProcessor.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/TextProcessor.kt index b058627..d8b44eb 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/TextProcessor.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/TextProcessor.kt @@ -11,7 +11,8 @@ import space.kscience.dataforge.names.parseAsName */ @Type(TextProcessor.TYPE) public fun interface TextProcessor { - context(WebPage) public fun process(text: String): String + context(WebPage) + public fun process(text: String): String public companion object { public const val TYPE: String = "snark.textTransformation" @@ -31,23 +32,27 @@ public object BasicTextProcessor : TextProcessor { 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) { "homeRef" -> homeRef "resolveRef" -> { val refString = match.groups[2]?.value ?: error("resolveRef requires a string (quoted) argument") resolveRef(refString) } + "resolvePageRef" -> { val refString = match.groups[2]?.value ?: error("resolvePageRef requires a string (quoted) argument") - resolvePageRef(refString) + localisedPageRef(refString.parseAsName()) } + "pageMeta.get" -> { val nameString = match.groups[2]?.value ?: error("resolvePageRef requires a string (quoted) argument") pageMeta[nameString.parseAsName()].string ?: "@null" } + else -> match.value } } diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/WebPage.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/WebPage.kt index 05bd9c2..d7f338b 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/WebPage.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/WebPage.kt @@ -9,7 +9,8 @@ import space.kscience.dataforge.meta.string import space.kscience.dataforge.names.* 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()) { "${it.body}[${it.index}]" } 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 val snark: SnarkHtmlPlugin @@ -25,14 +29,28 @@ public interface WebPage : ContextAware, SnarkContext { public val data: DataTree<*> + /** + * A metadata for a page. It should include site metadata + */ public val pageMeta: Meta + /** + * Resolve absolute url for given [ref] + * + */ 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()) @@ -41,7 +59,8 @@ public val WebPage.homeRef: String get() = resolvePageRef(SiteBuilder.INDEX_PAGE /** * 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(name) ?: getByType(name + SiteBuilder.INDEX_PAGE_TOKEN)) 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 */ -context(SnarkContext) public fun DataTree<*>.resolveAllHtml(predicate: (name: Name, meta: Meta) -> Boolean): Map = +context(SnarkContext) +public fun DataTree<*>.resolveAllHtml(predicate: (name: Name, meta: Meta) -> Boolean): Map = filterByType { name, meta -> predicate(name, meta) && meta["published"].string != "false" @@ -60,7 +80,8 @@ context(SnarkContext) public fun DataTree<*>.resolveAllHtml(predicate: (name: Na }.asSequence().associate { it.name to it.data } -context(SnarkContext) public fun DataTree<*>.findByContentType( +context(SnarkContext) +public fun DataTree<*>.findByContentType( contentType: String, baseName: Name = Name.EMPTY, ): Map> = resolveAllHtml { name, meta -> diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/language.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/language.kt new file mode 100644 index 0000000..eac5566 --- /dev/null +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/language.kt @@ -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 + 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, 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, + 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 + 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) +} \ No newline at end of file diff --git a/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt b/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt index 430798d..015f707 100644 --- a/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt +++ b/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt @@ -16,11 +16,12 @@ import kotlinx.html.CommonAttributeGroupFacade import kotlinx.html.HTML import kotlinx.html.style import space.kscience.dataforge.data.DataTree +import space.kscience.dataforge.meta.Laminate import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.withDefault import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.cutLast import space.kscience.dataforge.names.endsWith +import space.kscience.dataforge.names.plus import space.kscience.snark.SnarkEnvironment import space.kscience.snark.html.* import java.nio.file.Path @@ -37,6 +38,7 @@ public class KtorSiteBuilder( override val data: DataTree<*>, override val siteMeta: Meta, private val baseUrl: String, + override val route: Name, private val ktorRoute: Route, ) : SiteBuilder { @@ -64,21 +66,27 @@ public class KtorSiteBuilder( private inner class KtorWebPage( val pageBaseUrl: String, - override val pageMeta: Meta = this@KtorSiteBuilder.siteMeta, + override val pageMeta: Meta, ) : WebPage { override val snark: SnarkHtmlPlugin get() = this@KtorSiteBuilder.snark override val data: DataTree<*> get() = this@KtorSiteBuilder.data override fun resolveRef(ref: String): String = resolveRef(pageBaseUrl, ref) - override fun resolvePageRef(pageName: Name): String = if (pageName.endsWith(SiteBuilder.INDEX_PAGE_TOKEN)) { - resolveRef(pageName.cutLast().toWebPath()) - } else { - resolveRef(pageName.toWebPath()) + override fun resolvePageRef( + 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 { + 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()) { call.respondHtml { val request = call.request @@ -89,7 +97,7 @@ public class KtorSiteBuilder( port = request.origin.port } - val pageBuilder = KtorWebPage(url.buildString()) + val pageBuilder = KtorWebPage(url.buildString(), Laminate(pageMeta, siteMeta)) content(pageBuilder, this) } } @@ -98,17 +106,26 @@ public class KtorSiteBuilder( override fun route( routeName: Name, dataOverride: DataTree<*>?, - metaOverride: Meta?, - setAsRoot: Boolean, + routeMeta: Meta, ): SiteBuilder = KtorSiteBuilder( snark = snark, data = dataOverride ?: data, - siteMeta = metaOverride?.withDefault(siteMeta) ?: siteMeta, - baseUrl = if (setAsRoot) { - resolveRef(baseUrl, routeName.toWebPath()) - } else { - baseUrl - }, + siteMeta = Laminate(routeMeta, siteMeta), + baseUrl = baseUrl, + route = this.route + routeName, + ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath()) + ) + + 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()) ) @@ -122,17 +139,19 @@ public class KtorSiteBuilder( } } -context(Route, SnarkEnvironment) private fun siteInRoute( +context(Route, SnarkEnvironment) +private fun siteInRoute( baseUrl: String = "", block: KtorSiteBuilder.() -> Unit, ) { contract { 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 = "", block: KtorSiteBuilder.() -> Unit, ) {