Full refactoring

This commit is contained in:
Alexander Nozik 2023-11-30 22:04:13 +03:00
parent eeaa080a88
commit 738f41265f
18 changed files with 579 additions and 837 deletions

View File

@ -21,8 +21,8 @@ public class Snark : WorkspacePlugin() {
public val io: IOPlugin by require(IOPlugin) public val io: IOPlugin by require(IOPlugin)
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
public val readers: Map<Name, SnarkIOReader<Any>> by lazy { public val readers: Map<Name, SnarkReader<Any>> by lazy {
context.gather<SnarkIOReader<Any>>(SnarkIOReader.DF_TYPE, inherit = true) context.gather<SnarkReader<Any>>(SnarkReader.DF_TYPE, inherit = true)
} }
/** /**
@ -50,7 +50,7 @@ public class Snark : WorkspacePlugin() {
readByteArray() readByteArray()
} }
internal val byteArraySnarkParser = SnarkIOReader(byteArrayIOReader) internal val byteArraySnarkParser = SnarkReader(byteArrayIOReader)
} }
} }

View File

@ -3,11 +3,11 @@ package space.kscience.snark
import space.kscience.dataforge.io.IOReader import space.kscience.dataforge.io.IOReader
import space.kscience.dataforge.io.asBinary import space.kscience.dataforge.io.asBinary
import space.kscience.dataforge.misc.DfId import space.kscience.dataforge.misc.DfId
import space.kscience.snark.SnarkIOReader.Companion.DEFAULT_PRIORITY import space.kscience.snark.SnarkReader.Companion.DEFAULT_PRIORITY
import space.kscience.snark.SnarkIOReader.Companion.DF_TYPE import space.kscience.snark.SnarkReader.Companion.DF_TYPE
@DfId(DF_TYPE) @DfId(DF_TYPE)
public interface SnarkIOReader<out T>: IOReader<T> { public interface SnarkReader<out T>: IOReader<T> {
public val types: Set<String> public val types: Set<String>
public val priority: Int get() = DEFAULT_PRIORITY public val priority: Int get() = DEFAULT_PRIORITY
public fun readFrom(source: String): T public fun readFrom(source: String): T
@ -27,17 +27,17 @@ public interface SnarkIOReader<out T>: IOReader<T> {
* @property priority The priority of the SnarkIOReader. Higher priority SnarkIOReader instances will be preferred over lower priority ones. * @property priority The priority of the SnarkIOReader. Higher priority SnarkIOReader instances will be preferred over lower priority ones.
*/ */
private class SnarkIOReaderWrapper<out T>( private class SnarkReaderWrapper<out T>(
private val reader: IOReader<T>, private val reader: IOReader<T>,
override val types: Set<String>, override val types: Set<String>,
override val priority: Int = DEFAULT_PRIORITY, override val priority: Int = DEFAULT_PRIORITY,
) : IOReader<T> by reader, SnarkIOReader<T> { ) : IOReader<T> by reader, SnarkReader<T> {
override fun readFrom(source: String): T = readFrom(source.encodeToByteArray().asBinary()) override fun readFrom(source: String): T = readFrom(source.encodeToByteArray().asBinary())
} }
public fun <T : Any> SnarkIOReader( public fun <T : Any> SnarkReader(
reader: IOReader<T>, reader: IOReader<T>,
vararg types: String, vararg types: String,
priority: Int = DEFAULT_PRIORITY priority: Int = DEFAULT_PRIORITY
): SnarkIOReader<T> = SnarkIOReaderWrapper(reader, types.toSet(), priority) ): SnarkReader<T> = SnarkReaderWrapper(reader, types.toSet(), priority)

View File

@ -1,48 +0,0 @@
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.meta.*
import space.kscience.dataforge.names.Name
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 {
public val DEFAULT: DataRenderer = object : DataRenderer {
context(SiteBuilder)
override fun invoke(name: Name, data: Data<Any>) {
if (data.type == typeOf<HtmlData>()) {
val languageMeta: Meta = Language.forName(name)
val dataMeta: Meta = if (languageMeta.isEmpty()) {
data.meta
} else {
data.meta.toMutableMeta().apply {
"languages" put languageMeta
}
}
page(name, dataMeta) {
head {
title = dataMeta["title"].string ?: "Untitled page"
}
body {
@Suppress("UNCHECKED_CAST")
htmlData(data as HtmlData)
}
}
}
}
}
}
}

View File

@ -20,8 +20,8 @@ public fun interface HtmlFragment {
public typealias HtmlData = Data<HtmlFragment> public typealias HtmlData = Data<HtmlFragment>
context(WebPage)
public fun FlowContent.htmlData(data: HtmlData): Unit = runBlocking(Dispatchers.IO) { public fun FlowContent.htmlData(page: PageContext, data: HtmlData): Unit = runBlocking(Dispatchers.IO) {
withSnarkPage(page) { withSnarkPage(page) {
with(data.await()) { consumer.renderFragment() } with(data.await()) { consumer.renderFragment() }
} }

View File

@ -0,0 +1,45 @@
package space.kscience.snark.html
import kotlinx.html.HTML
import space.kscience.dataforge.data.DataSet
import space.kscience.dataforge.data.DataSetBuilder
import space.kscience.dataforge.data.node
import space.kscience.dataforge.data.static
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.Name
public fun interface HtmlPage {
public fun HTML.renderPage(pageContext: PageContext, pageData: DataSet<*>)
}
// data builders
public fun DataSetBuilder<Any>.page(name: Name, pageMeta: Meta = Meta.EMPTY, block: HTML.(pageContext: PageContext, pageData: DataSet<Any>) -> Unit) {
val page = HtmlPage(block)
static<HtmlPage>(name, page, pageMeta)
}
// if (data.type == typeOf<HtmlData>()) {
// val languageMeta: Meta = Language.forName(name)
//
// val dataMeta: Meta = if (languageMeta.isEmpty()) {
// data.meta
// } else {
// data.meta.toMutableMeta().apply {
// "languages" put languageMeta
// }
// }
//
// page(name, dataMeta) { pageContext->
// head {
// title = dataMeta["title"].string ?: "Untitled page"
// }
// body {
// @Suppress("UNCHECKED_CAST")
// htmlData(pageContext, data as HtmlData)
// }
// }
// }

View File

@ -0,0 +1,36 @@
package space.kscience.snark.html
import space.kscience.dataforge.data.DataSet
import space.kscience.dataforge.data.DataSetBuilder
import space.kscience.dataforge.data.static
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.getIndexed
import space.kscience.dataforge.meta.string
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.asName
import space.kscience.dataforge.names.parseAsName
public fun interface HtmlSite {
public fun renderSite(siteContext: SiteContext, siteData: DataSet<Any>)
}
public fun DataSetBuilder<Any>.site(
name: Name,
siteMeta: Meta,
block: (siteContext: SiteContext, siteData: DataSet<Any>) -> Unit,
) {
static(name, HtmlSite(block), siteMeta)
}
//public fun DataSetBuilder<Any>.site(name: Name, block: DataSetBuilder<Any>.() -> Unit) {
// node(name, block)
//}
internal fun DataSetBuilder<Any>.assetsFrom(rootMeta: Meta) {
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
val webName: String? by meta.string()
val name by meta.string { error("File path is not provided") }
val fileName = name.parseAsName()
static(fileName, webName?.parseAsName() ?: fileName)
}
}

View File

@ -1,6 +1,7 @@
package space.kscience.snark.html package space.kscience.snark.html
import space.kscience.dataforge.data.getItem import space.kscience.dataforge.data.DataSet
import space.kscience.dataforge.data.branch
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.* import space.kscience.dataforge.names.*
import space.kscience.snark.SnarkBuilder import space.kscience.snark.SnarkBuilder
@ -8,7 +9,6 @@ import space.kscience.snark.html.Language.Companion.SITE_LANGUAGES_KEY
import space.kscience.snark.html.Language.Companion.SITE_LANGUAGE_KEY import space.kscience.snark.html.Language.Companion.SITE_LANGUAGE_KEY
public class Language : Scheme() { public class Language : Scheme() {
/** /**
* Language key override * Language key override
@ -35,49 +35,50 @@ public class Language : Scheme() {
public val LANGUAGES_KEY: Name = "languages".asName() public val LANGUAGES_KEY: Name = "languages".asName()
public val SITE_LANGUAGE_KEY: Name = SiteBuilder.SITE_META_KEY + LANGUAGE_KEY public val SITE_LANGUAGE_KEY: Name = SiteContext.SITE_META_KEY + LANGUAGE_KEY
public val SITE_LANGUAGES_KEY: Name = SiteBuilder.SITE_META_KEY + LANGUAGES_KEY public val SITE_LANGUAGES_KEY: Name = SiteContext.SITE_META_KEY + LANGUAGES_KEY
public const val DEFAULT_LANGUAGE: String = "en" public const val DEFAULT_LANGUAGE: String = "en"
//
/** // /**
* Automatically build a language map for a data piece with given [name] based on existence of appropriate data nodes. // * Automatically build a language map for a data piece with given [name] based on existence of appropriate data nodes.
*/ // */
context(SiteBuilder) // context(SiteContext)
public fun forName(name: Name): Meta = Meta { // public fun forName(name: Name): Meta = Meta {
val currentLanguagePrefix = languages[language]?.get(Language::prefix.name)?.string ?: language // val currentLanguagePrefix = languages[language]?.get(Language::prefix.name)?.string ?: language
val fullName = (route.removeFirstOrNull(currentLanguagePrefix.asName()) ?: route) + name // val fullName = (route.removeFirstOrNull(currentLanguagePrefix.asName()) ?: route) + name
languages.forEach { (key, meta) -> // languages.forEach { (key, meta) ->
val languagePrefix: String = meta[Language::prefix.name].string ?: key // val languagePrefix: String = meta[Language::prefix.name].string ?: key
val nameWithLanguage: Name = if (languagePrefix.isBlank()) { // val nameWithLanguage: Name = if (languagePrefix.isBlank()) {
fullName // fullName
} else { // } else {
languagePrefix.asName() + fullName // languagePrefix.asName() + fullName
} // }
if (data.getItem(name) != null) { // if (resolveData.getItem(name) != null) {
key put meta.asMutableMeta().apply { // key put meta.asMutableMeta().apply {
Language::target.name put nameWithLanguage.toString() // Language::target.name put nameWithLanguage.toString()
} // }
} // }
} // }
} // }
} }
} }
public val SiteBuilder.languages: Map<String, Meta> public val SiteContext.languages: Map<String, Meta>
get() = siteMeta[SITE_LANGUAGES_KEY]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap() get() = siteMeta[SITE_LANGUAGES_KEY]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap()
public val SiteBuilder.language: String public val SiteContext.language: String
get() = siteMeta[SITE_LANGUAGE_KEY].string ?: Language.DEFAULT_LANGUAGE get() = siteMeta[SITE_LANGUAGE_KEY].string ?: Language.DEFAULT_LANGUAGE
public val SiteBuilder.languagePrefix: Name public val SiteContext.languagePrefix: Name
get() = languages[language]?.let { it[Language::prefix.name].string ?: language }?.parseAsName() ?: Name.EMPTY get() = languages[language]?.let { it[Language::prefix.name].string ?: language }?.parseAsName() ?: Name.EMPTY
public fun SiteBuilder.withLanguages(languageMap: Map<String, Meta>, block: SiteBuilder.(language: String) -> Unit) { @SnarkBuilder
public suspend fun SiteContext.multiLanguageSite(data: DataSet<Any>, languageMap: Map<String, Meta>, site: HtmlSite) {
languageMap.forEach { (languageKey, languageMeta) -> languageMap.forEach { (languageKey, languageMeta) ->
val prefix = languageMeta[Language::prefix.name].string ?: languageKey val prefix = languageMeta[Language::prefix.name].string ?: languageKey
val routeMeta = Meta { val languageSiteMeta = Meta {
SITE_LANGUAGE_KEY put languageKey SITE_LANGUAGE_KEY put languageKey
SITE_LANGUAGES_KEY put Meta { SITE_LANGUAGES_KEY put Meta {
languageMap.forEach { languageMap.forEach {
@ -85,65 +86,48 @@ public fun SiteBuilder.withLanguages(languageMap: Map<String, Meta>, block: Site
} }
} }
} }
route(prefix, routeMeta = routeMeta) { site(prefix.parseAsName(), data.branch(prefix), siteMeta = Laminate(languageSiteMeta, siteMeta), site)
block(languageKey)
}
} }
} }
@SnarkBuilder
public fun SiteBuilder.withLanguages(
vararg language: Pair<String, String>,
block: SiteBuilder.(language: String) -> Unit,
) {
val languageMap = language.associate {
it.first to Meta {
Language::prefix.name put it.second
}
}
withLanguages(languageMap, block)
}
/** /**
* The language key of this page * The language key of this page
*/ */
public val WebPage.language: String public val PageContext.language: String
get() = pageMeta[Language.LANGUAGE_KEY]?.string ?: pageMeta[SITE_LANGUAGE_KEY]?.string ?: Language.DEFAULT_LANGUAGE get() = pageMeta[Language.LANGUAGE_KEY]?.string ?: pageMeta[SITE_LANGUAGE_KEY]?.string ?: Language.DEFAULT_LANGUAGE
/** /**
* Mapping of language keys to other language versions of this page * Mapping of language keys to other language versions of this page
*/ */
public val WebPage.languages: Map<String, Meta> public val PageContext.languages: Map<String, Meta>
get() = pageMeta[Language.LANGUAGES_KEY]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap() get() = pageMeta[Language.LANGUAGES_KEY]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap()
public fun WebPage.localisedPageRef(pageName: Name, relative: Boolean = false): String { public fun PageContext.localisedPageRef(pageName: Name, relative: Boolean = false): String {
val prefix = languages[language]?.get(Language::prefix.name)?.string?.parseAsName() ?: Name.EMPTY val prefix = languages[language]?.get(Language::prefix.name)?.string?.parseAsName() ?: Name.EMPTY
return resolvePageRef(prefix + pageName, relative) return resolvePageRef(prefix + pageName, relative)
} }
//
/** ///**
* Render all pages in a node with given name. Use localization prefix if appropriate data is available. // * Render all pages in a node with given name. Use localization prefix if appropriate data is available.
*/ // */
public fun SiteBuilder.localizedPages( //public fun SiteContext.localizedPages(
dataPath: Name, // dataPath: Name,
remotePath: Name = dataPath, // remotePath: Name = dataPath,
dataRenderer: DataRenderer = DataRenderer.DEFAULT, // dataRenderer: DataRenderer = DataRenderer.DEFAULT,
) { //) {
val item = data.getItem(languagePrefix + dataPath) // val item = resolveData.getItem(languagePrefix + dataPath)
?: data.getItem(dataPath) // ?: resolveData.getItem(dataPath)
?: error("No data found by name $dataPath") // ?: error("No data found by name $dataPath")
route(remotePath) { // route(remotePath) {
pages(item, dataRenderer) // pages(item, dataRenderer)
} // }
} //}
//
public fun SiteBuilder.localizedPages( //public fun SiteContext.localizedPages(
dataPath: String, // dataPath: String,
remotePath: Name = dataPath.parseAsName(), // remotePath: Name = dataPath.parseAsName(),
dataRenderer: DataRenderer = DataRenderer.DEFAULT, // dataRenderer: DataRenderer = DataRenderer.DEFAULT,
) { //) {
localizedPages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer) // localizedPages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
} //}

View File

@ -1,5 +1,6 @@
package space.kscience.snark.html package space.kscience.snark.html
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.* import space.kscience.dataforge.data.*
@ -10,7 +11,6 @@ import space.kscience.dataforge.names.*
import space.kscience.snark.SnarkBuilder import space.kscience.snark.SnarkBuilder
import space.kscience.snark.SnarkContext import space.kscience.snark.SnarkContext
context(SnarkContext)
public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") { public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") {
if (it.hasIndex()) { if (it.hasIndex()) {
"${it.body}[${it.index}]" "${it.body}[${it.index}]"
@ -23,13 +23,7 @@ public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") {
* A context for building a single page * A context for building a single page
*/ */
@SnarkBuilder @SnarkBuilder
public interface WebPage : ContextAware, SnarkContext { public interface PageContext : SnarkContext {
public val snark: SnarkHtml
override val context: Context get() = snark.context
public val data: DataTree<*>
/** /**
* A metadata for a page. It should include site metadata * A metadata for a page. It should include site metadata
@ -45,27 +39,27 @@ public interface WebPage : ContextAware, SnarkContext {
/** /**
* Resolve absolute url for a page with given [pageName]. * Resolve absolute url for a page with given [pageName].
* *
* @param relative if true, add [SiteBuilder] route to the absolute page name * @param relative if true, add [SiteContext] route to the absolute page name
*/ */
public fun resolvePageRef(pageName: Name, relative: Boolean = false): String public fun resolvePageRef(pageName: Name, relative: Boolean = false): String
} }
context(WebPage) context(PageContext)
public val page: WebPage public val page: PageContext
get() = this@WebPage get() = this@PageContext
public fun WebPage.resolvePageRef(pageName: String): String = resolvePageRef(pageName.parseAsName()) public fun PageContext.resolvePageRef(pageName: String): String = resolvePageRef(pageName.parseAsName())
public val WebPage.homeRef: String get() = resolvePageRef(SiteBuilder.INDEX_PAGE_TOKEN.asName()) public val PageContext.homeRef: String get() = resolvePageRef(SiteContext.INDEX_PAGE_TOKEN.asName())
public val WebPage.name: Name? get() = pageMeta["name"].string?.parseAsName() public val PageContext.name: Name? get() = pageMeta["name"].string?.parseAsName()
/** /**
* Resolve a Html builder by its full name * Resolve a Html builder by its full name
*/ */
context(SnarkContext) context(SnarkContext)
public fun DataTree<*>.resolveHtmlOrNull(name: Name): HtmlData? { public fun DataTree<*>.resolveHtmlOrNull(name: Name): HtmlData? {
val resolved = (getByType<HtmlFragment>(name) ?: getByType<HtmlFragment>(name + SiteBuilder.INDEX_PAGE_TOKEN)) val resolved = (getByType<HtmlFragment>(name) ?: getByType<HtmlFragment>(name + SiteContext.INDEX_PAGE_TOKEN))
return resolved?.takeIf { return resolved?.takeIf {
it.published //TODO add language confirmation it.published //TODO add language confirmation

View File

@ -1,248 +0,0 @@
package space.kscience.snark.html
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.branch
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.SnarkBuilder
import space.kscience.snark.SnarkContext
import space.kscience.snark.html.SiteLayout.Companion.LAYOUT_KEY
/**
* An abstraction, which is used to render sites to the different rendering engines
*/
@SnarkBuilder
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
*/
public val data: DataTree<*>
/**
* Snark plugin and context used for layout resolution, preprocessors, etc
*/
public val snark: SnarkHtml
override val context: Context get() = snark.context
/**
* Site configuration
*/
public val siteMeta: Meta
/**
* Serve static data as a file from [data] with given [dataName] at given [routeName].
*/
public fun static(dataName: Name, routeName: Name = dataName)
/**
* 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]
*/
@SnarkBuilder
public fun page(route: Name = Name.EMPTY, pageMeta: Meta = Meta.EMPTY, content: context(HTML, WebPage) () -> Unit)
/**
* Create a route with optional data tree override. For example one could use a subtree of the initial tree.
* By default, the same data tree is used for route.
*/
public fun route(
routeName: Name,
dataOverride: DataTree<*>? = null,
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 SITE_META_KEY: Name = "site".asName()
public val INDEX_PAGE_TOKEN: NameToken = NameToken("index")
public val UP_PAGE_TOKEN: NameToken = NameToken("..")
}
}
context(SiteBuilder)
public val site: SiteBuilder
get() = this@SiteBuilder
@SnarkBuilder
public inline fun SiteBuilder.route(
route: Name,
dataOverride: DataTree<*>? = null,
routeMeta: Meta = Meta.EMPTY,
block: SiteBuilder.() -> Unit,
) {
route(route, dataOverride, routeMeta).apply(block)
}
@SnarkBuilder
public inline fun SiteBuilder.route(
route: String,
dataOverride: DataTree<*>? = null,
routeMeta: Meta = Meta.EMPTY,
block: SiteBuilder.() -> Unit,
) {
route(route.parseAsName(), dataOverride, routeMeta).apply(block)
}
@SnarkBuilder
public inline fun SiteBuilder.site(
route: Name,
dataOverride: DataTree<*>? = null,
routeMeta: Meta = Meta.EMPTY,
block: SiteBuilder.() -> Unit,
) {
site(route, dataOverride, routeMeta).apply(block)
}
@SnarkBuilder
public inline fun SiteBuilder.site(
route: String,
dataOverride: DataTree<*>? = null,
routeMeta: Meta = Meta.EMPTY,
block: SiteBuilder.() -> Unit,
) {
site(route.parseAsName(), dataOverride, routeMeta).apply(block)
}
public inline fun SiteBuilder.withData(
data: DataTree<*>,
block: SiteBuilder.() -> Unit
){
route(Name.EMPTY, data).apply(block)
}
public inline fun SiteBuilder.withDataBranch(
name: Name,
block: SiteBuilder.() -> Unit
){
route(Name.EMPTY, data.branch(name)).apply(block)
}
public inline fun SiteBuilder.withDataBranch(
name: String,
block: SiteBuilder.() -> Unit
){
route(Name.EMPTY, data.branch(name)).apply(block)
}
///**
// * Create a stand-alone site at a given node
// */
//public fun SiteBuilder.site(route: Name, dataRoot: DataTree<*>, block: SiteBuilder.() -> Unit) {
// val mountedData = data.copy(
// data = dataRoot,
// baseUrlPath = data.resolveRef(route.tokens.joinToString(separator = "/")),
// meta = Laminate(dataRoot.meta, data.meta) //layering dataRoot meta over existing data
// )
// route(route) {
// withData(mountedData).block()
// }
//}
public fun SiteBuilder.static(dataName: String): Unit = static(dataName.parseAsName())
public fun SiteBuilder.static(dataName: String, routeName: String): Unit = static(
dataName.parseAsName(),
routeName.parseAsName()
)
internal fun SiteBuilder.assetsFrom(rootMeta: Meta) {
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
val webName: String? by meta.string()
val name by meta.string { error("File path is not provided") }
val fileName = name.parseAsName()
static(fileName, webName?.parseAsName() ?: fileName)
}
}
/**
* 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

@ -0,0 +1,177 @@
package space.kscience.snark.html
import kotlinx.html.HTML
import space.kscience.dataforge.data.*
import space.kscience.dataforge.io.Binary
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.NameToken
import space.kscience.dataforge.names.asName
import space.kscience.dataforge.workspace.Workspace
import space.kscience.snark.SnarkBuilder
import space.kscience.snark.SnarkContext
/**
* An abstraction, which is used to render sites to the different rendering engines
*/
@SnarkBuilder
public interface SiteContext : SnarkContext {
/**
* Route name of this [SiteContext] relative to the site root
*/
public val route: Name
/**
* Site configuration
*/
public val siteMeta: Meta
/**
* Renders a static file or resource for the given route and data.
*
* @param route The route name of the static file relative to the site root.
* @param data The data object containing the binary data for the static file.
*/
public suspend fun static(route: Name, data: Data<Binary>)
/**
* Create a single page at given [route]. If route is empty, create an index page at current route.
*
* @param pageMeta additional page meta. [PageContext] will use both it and [siteMeta]
*/
@SnarkBuilder
public suspend fun page(
route: Name,
data: DataSet<Any>,
pageMeta: Meta = Meta.EMPTY,
htmlPage: HtmlPage,
)
/**
* Creates a sub-site and sets it as site base url
* @param route mount site at [rootName]
* @param dataPrefix prefix path for data used in this site
*/
@SnarkBuilder
public suspend fun site(
route: Name,
data: DataSet<Any>,
siteMeta: Meta = Meta.EMPTY,
htmlSite: HtmlSite,
)
public companion object {
public val SITE_META_KEY: Name = "site".asName()
public val INDEX_PAGE_TOKEN: NameToken = NameToken("index")
public val UP_PAGE_TOKEN: NameToken = NameToken("..")
}
}
@SnarkBuilder
public suspend fun SiteContext.page(
route: Name,
data: DataSet<Any>,
pageMeta: Meta = Meta.EMPTY,
htmlPage: HTML.(page: PageContext, data: DataSet<Any>) -> Unit,
): Unit = page(route, data, pageMeta, HtmlPage(htmlPage))
context(SiteContext)
public val site: SiteContext
get() = this@SiteContext
public suspend fun SiteContext.renderPages(data: DataSet<Any>): Unit {
// Render all sub-sites
data.filterByType<HtmlSite>().forEach { siteData: NamedData<HtmlSite> ->
// generate a sub-site context and render the data in sub-site context
val dataPrefix = siteData.meta["site.dataPath"].string?.asName() ?: Name.EMPTY
site(
route = siteData.meta["site.route"].string?.asName() ?: siteData.name,
data.branch(dataPrefix),
siteMeta = siteData.meta,
siteData.await()
)
}
// Render all stand-alone pages in default site
data.filterByType<HtmlPage>().forEach { pageData: NamedData<HtmlPage> ->
val dataPrefix = pageData.meta["page.dataPath"].string?.asName() ?: Name.EMPTY
page(
route = pageData.meta["page.route"].string?.asName() ?: pageData.name,
data.branch(dataPrefix),
pageMeta = pageData.meta,
pageData.await()
)
}
}
//
///**
// * 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 SiteContext.pages(
// dataRenderer: DataRenderer = DataRenderer.DEFAULT,
//) {
// val layoutMeta = siteData().meta[LAYOUT_KEY]
// if (layoutMeta != null) {
// //use layout if it is defined
// snark.siteLayout(layoutMeta).render(siteData())
// } else {
// when (siteData()) {
// is DataTreeItem.Node -> {
// siteData().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, siteData().data)
// }
// }
// siteData().meta[SiteLayout.ASSETS_KEY]?.let {
// assetsFrom(it)
// }
// }
// //TODO watch for changes
//}
//
///**
// * Render all pages in a node with given name
// */
//public fun SiteContext.pages(
// dataPath: Name,
// remotePath: Name = dataPath,
// dataRenderer: DataRenderer = DataRenderer.DEFAULT,
//) {
// val item = resolveData.getItem(dataPath) ?: error("No data found by name $dataPath")
// route(remotePath) {
// pages(item, dataRenderer)
// }
//}
//
//public fun SiteContext.pages(
// dataPath: String,
// remotePath: Name = dataPath.parseAsName(),
// dataRenderer: DataRenderer = DataRenderer.DEFAULT,
//) {
// pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
//}

View File

@ -1,32 +0,0 @@
package space.kscience.snark.html
import space.kscience.dataforge.data.DataTreeItem
import space.kscience.dataforge.misc.DfId
import space.kscience.dataforge.names.NameToken
/**
* An abstraction to render singular data or a data tree.
*/
@DfId(SiteLayout.TYPE)
public fun interface SiteLayout {
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")
}
}
/**
* 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<*>) {
pages(item)
}
}

View File

@ -6,7 +6,6 @@ import io.ktor.http.ContentType
import kotlinx.io.readByteArray import kotlinx.io.readByteArray
import space.kscience.dataforge.context.* import space.kscience.dataforge.context.*
import space.kscience.dataforge.data.* import space.kscience.dataforge.data.*
import space.kscience.dataforge.io.Binary
import space.kscience.dataforge.io.IOPlugin import space.kscience.dataforge.io.IOPlugin
import space.kscience.dataforge.io.IOReader import space.kscience.dataforge.io.IOReader
import space.kscience.dataforge.io.JsonMetaFormat import space.kscience.dataforge.io.JsonMetaFormat
@ -21,18 +20,12 @@ import space.kscience.dataforge.provider.dfId
import space.kscience.dataforge.workspace.* import space.kscience.dataforge.workspace.*
import space.kscience.snark.ImageIOReader import space.kscience.snark.ImageIOReader
import space.kscience.snark.Snark import space.kscience.snark.Snark
import space.kscience.snark.SnarkIOReader import space.kscience.snark.SnarkReader
import space.kscience.snark.TextProcessor import space.kscience.snark.TextProcessor
import java.net.URLConnection import java.net.URLConnection
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.extension import kotlin.io.path.extension
public fun <T : Any> SnarkIOReader(
reader: IOReader<T>,
vararg types: ContentType,
priority: Int = SnarkIOReader.DEFAULT_PRIORITY,
): SnarkIOReader<T> = SnarkIOReader(reader, *types.map { it.toString() }.toTypedArray(), priority = priority)
/** /**
* A plugin used for rendering a [DataTree] as HTML * A plugin used for rendering a [DataTree] as HTML
@ -44,33 +37,17 @@ public class SnarkHtml : WorkspacePlugin() {
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
/**
* Lazy-initialized variable that holds a map of site layouts.
*
* @property siteLayouts The map of site layouts, where the key is the layout name and the value is the corresponding SiteLayout object.
*/
private val siteLayouts: Map<Name, SiteLayout> by lazy {
context.gather(SiteLayout.TYPE, true)
}
internal fun siteLayout(layoutMeta: Meta): SiteLayout {
val layoutName = layoutMeta.string
?: layoutMeta["name"].string ?: error("Layout name not defined in $layoutMeta")
return siteLayouts[layoutName.parseAsName()] ?: error("Layout with name $layoutName not found in $this")
}
override fun content(target: String): Map<Name, Any> = when (target) { override fun content(target: String): Map<Name, Any> = when (target) {
SnarkIOReader::class.dfId -> mapOf( SnarkReader::class.dfId -> mapOf(
"html".asName() to HtmlReader, "html".asName() to HtmlReader,
"markdown".asName() to MarkdownReader, "markdown".asName() to MarkdownReader,
"json".asName() to SnarkIOReader(JsonMetaFormat, ContentType.Application.Json), "json".asName() to SnarkReader(JsonMetaFormat, ContentType.Application.Json.toString()),
"yaml".asName() to SnarkIOReader(YamlMetaFormat, "text/yaml", "yaml"), "yaml".asName() to SnarkReader(YamlMetaFormat, "text/yaml", "yaml"),
"png".asName() to SnarkIOReader(ImageIOReader, ContentType.Image.PNG), "png".asName() to SnarkReader(ImageIOReader, ContentType.Image.PNG.toString()),
"jpg".asName() to SnarkIOReader(ImageIOReader, ContentType.Image.JPEG), "jpg".asName() to SnarkReader(ImageIOReader, ContentType.Image.JPEG.toString()),
"gif".asName() to SnarkIOReader(ImageIOReader, ContentType.Image.GIF), "gif".asName() to SnarkReader(ImageIOReader, ContentType.Image.GIF.toString()),
"svg".asName() to SnarkIOReader(IOReader.binary, ContentType.Image.SVG, ContentType.parse("svg")), "svg".asName() to SnarkReader(IOReader.binary, ContentType.Image.SVG.toString(), "svg"),
"raw".asName() to SnarkIOReader( "raw".asName() to SnarkReader(
IOReader.binary, IOReader.binary,
"css", "css",
"js", "js",
@ -86,13 +63,6 @@ public class SnarkHtml : WorkspacePlugin() {
else -> super.content(target) else -> super.content(target)
} }
// public val assets: TaskReference<Binary> by task<Binary> {
// node(Name.EMPTY, from(allData).filter { name, meta ->
//
// })
// }
public val preprocess: TaskReference<String> by task<String> { public val preprocess: TaskReference<String> by task<String> {
pipeFrom<String, String>(dataByType<String>()) { text, _, meta -> pipeFrom<String, String>(dataByType<String>()) { text, _, meta ->
meta[TextProcessor.TEXT_TRANSFORMATION_KEY]?.let { meta[TextProcessor.TEXT_TRANSFORMATION_KEY]?.let {
@ -128,6 +98,10 @@ public class SnarkHtml : WorkspacePlugin() {
} }
// public val site by task<Any> {
//
// }
// public val textTransformationAction: Action<String, String> = Action.map<String, String> { // public val textTransformationAction: Action<String, String> = Action.map<String, String> {
// val transformations = actionMeta.getIndexed("transformation").entries.sortedBy { // val transformations = actionMeta.getIndexed("transformation").entries.sortedBy {
// it.key?.toIntOrNull() ?: 0 // it.key?.toIntOrNull() ?: 0
@ -144,7 +118,7 @@ public class SnarkHtml : WorkspacePlugin() {
readByteArray() readByteArray()
} }
internal val byteArraySnarkParser = SnarkIOReader(byteArrayIOReader) internal val byteArraySnarkParser = SnarkReader(byteArrayIOReader)
} }
} }

View File

@ -1,200 +0,0 @@
package space.kscience.snark.html
import kotlinx.coroutines.runBlocking
import kotlinx.html.HTML
import kotlinx.html.html
import kotlinx.html.stream.createHTML
import kotlinx.io.asSink
import kotlinx.io.buffered
import space.kscience.dataforge.data.DataTree
import space.kscience.dataforge.data.DataTreeItem
import space.kscience.dataforge.data.await
import space.kscience.dataforge.data.getItem
import space.kscience.dataforge.io.Binary
import space.kscience.dataforge.io.writeBinary
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.isEmpty
import space.kscience.dataforge.names.plus
import space.kscience.dataforge.workspace.FileData
import java.nio.file.Path
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.io.path.*
import kotlin.reflect.typeOf
/**
* An implementation of [SiteBuilder] to render site as a static directory [outputPath]
*/
internal class StaticSiteBuilder(
override val snark: SnarkHtml,
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) {
// Files.walk(this).forEach { source: Path ->
// val destination: Path = target.resolve(source.relativeTo(this))
// if (!destination.isDirectory()) {
// //avoid re-creating directories
// source.copyTo(destination, true)
// }
// }
// }
@OptIn(ExperimentalPathApi::class)
private suspend fun files(item: DataTreeItem<Any>, routeName: Name) {
//try using direct file rendering
item.meta[FileData.FILE_PATH_KEY]?.string?.let {
val file = Path.of(it)
val targetPath = outputPath.resolve(routeName.toWebPath())
targetPath.parent.createDirectories()
file.copyToRecursively(targetPath, followLinks = false)
//success, don't do anything else
return@files
}
when (item) {
is DataTreeItem.Leaf -> {
val datum = item.data
if (datum.type != typeOf<Binary>()) error("Can't directly serve file of type ${item.data.type}")
val targetPath = outputPath.resolve(routeName.toWebPath())
val binary = datum.await() as Binary
targetPath.outputStream().asSink().buffered().use {
it.writeBinary(binary)
}
}
is DataTreeItem.Node -> {
item.tree.items.forEach { (token, childItem) ->
files(childItem, routeName + token)
}
}
}
}
override fun static(dataName: Name, routeName: Name) {
val item: DataTreeItem<Any> = data.getItem(dataName) ?: error("Data with name $dataName is not resolved")
runBlocking {
files(item, routeName)
}
}
//
// override fun file(file: Path, webPath: String) {
// val targetPath = outputPath.resolve(webPath)
// if (file.isDirectory()) {
// targetPath.parent.createDirectories()
// file.copyRecursively(targetPath)
// } else if (webPath.isBlank()) {
// error("Can't mount file to an empty route")
// } else {
// targetPath.parent.createDirectories()
// file.copyTo(targetPath, true)
// }
// }
//
// override fun resourceFile(resourcesPath: String, webPath: String) {
// val targetPath = outputPath.resolve(webPath)
// targetPath.parent.createDirectories()
// javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyTo(targetPath, true)
// }
//
// override fun resourceDirectory(resourcesPath: String) {
// outputPath.parent.createDirectories()
// javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyRecursively(outputPath)
// }
private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
ref
} else if (ref.isEmpty()) {
baseUrl
} else {
"${baseUrl.removeSuffix("/")}/$ref"
}
inner class StaticWebPage(override val pageMeta: Meta) : WebPage {
override val data: DataTree<*> get() = this@StaticSiteBuilder.data
override val snark: SnarkHtml get() = this@StaticSiteBuilder.snark
override fun resolveRef(ref: String): String =
this@StaticSiteBuilder.resolveRef(this@StaticSiteBuilder.baseUrl, ref)
override fun resolvePageRef(pageName: Name, relative: Boolean): String = resolveRef(
(if (relative) this@StaticSiteBuilder.route + pageName else pageName).toWebPath() + ".html"
)
}
override fun page(route: Name, pageMeta: Meta, content: context(HTML) WebPage.() -> Unit) {
val htmlBuilder = createHTML()
val modifiedPageMeta = pageMeta.toMutableMeta().apply {
"name" put route.toString()
}
htmlBuilder.html {
content(this, StaticWebPage(Laminate(modifiedPageMeta, siteMeta)))
}
val newPath = if (route.isEmpty()) {
outputPath.resolve("index.html")
} else {
outputPath.resolve(route.toWebPath() + ".html")
}
newPath.parent.createDirectories()
newPath.writeText(htmlBuilder.finalize())
}
override fun route(
routeName: Name,
dataOverride: DataTree<*>?,
routeMeta: Meta,
): SiteBuilder = StaticSiteBuilder(
snark = snark,
data = dataOverride ?: data,
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 = if (baseUrl == "") "" else resolveRef(baseUrl, routeName.toWebPath()),
route = Name.EMPTY,
outputPath = outputPath.resolve(routeName.toWebPath())
)
}
/**
* 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.
*
*/
public fun SnarkHtml.static(
data: DataTree<*>,
outputPath: Path,
siteUrl: String = outputPath.absolutePathString().replace("\\", "/"),
siteMeta: Meta = data.meta,
block: SiteBuilder.() -> Unit,
) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
StaticSiteBuilder(this, data, siteMeta, siteUrl, Name.EMPTY, outputPath).block()
}

View File

@ -1,22 +1,19 @@
package space.kscience.snark.html package space.kscience.snark.html
import kotlinx.html.A import kotlinx.html.*
import kotlinx.html.FlowContent
import kotlinx.html.Tag
import kotlinx.html.TagConsumer
import space.kscience.dataforge.meta.string import space.kscience.dataforge.meta.string
import space.kscience.dataforge.names.parseAsName import space.kscience.dataforge.names.parseAsName
import space.kscience.snark.TextProcessor import space.kscience.snark.TextProcessor
public class WebPageTextProcessor(private val page: WebPage) : TextProcessor { public class WebPageTextProcessor(private val page: PageContext) : TextProcessor {
private val regex = """\$\{([\w.]*)(?>\("(.*)"\))?}""".toRegex() private val regex = """\$\{([\w.]*)(?>\("(.*)"\))?}""".toRegex()
/** /**
* A basic [TextProcessor] that replaces `${...}` expressions in text. The following expressions are recognised: * A basic [TextProcessor] that replaces `${...}` expressions in text. The following expressions are recognised:
* * `homeRef` resolves to [homeRef] * * `homeRef` resolves to [homeRef]
* * `resolveRef("...")` -> [WebPage.resolveRef] * * `resolveRef("...")` -> [PageContext.resolveRef]
* * `resolvePageRef("...")` -> [WebPage.resolvePageRef] * * `resolvePageRef("...")` -> [PageContext.resolvePageRef]
* * `pageMeta.get("...") -> [WebPage.pageMeta] get string method * * `pageMeta.get("...") -> [PageContext.pageMeta] get string method
* Otherwise return unchanged string * Otherwise return unchanged string
*/ */
override fun process(text: CharSequence): String = text.replace(regex) { match -> override fun process(text: CharSequence): String = text.replace(regex) { match ->
@ -45,9 +42,8 @@ public class WebPageTextProcessor(private val page: WebPage) : TextProcessor {
} }
public class WebPagePostprocessor<out R>( public class WebPagePostprocessor<out R>(
public val page: WebPage, public val page: PageContext,
private val consumer: TagConsumer<R>, private val consumer: TagConsumer<R>,
) : TagConsumer<R> by consumer { ) : TagConsumer<R> by consumer {
@ -64,9 +60,20 @@ public class WebPagePostprocessor<out R>(
override fun onTagContent(content: CharSequence) { override fun onTagContent(content: CharSequence) {
consumer.onTagContent(processor.process(content)) consumer.onTagContent(processor.process(content))
} }
override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
val proxy = object :Unsafe{
override fun String.unaryPlus() {
consumer.onTagContentUnsafe {
processor.process(this@unaryPlus).unaryPlus()
}
}
}
proxy.block()
}
} }
public inline fun FlowContent.withSnarkPage(page: WebPage, block: FlowContent.() -> Unit) { public inline fun FlowContent.withSnarkPage(page: PageContext, block: FlowContent.() -> Unit) {
val fc = object : FlowContent by this { val fc = object : FlowContent by this {
override val consumer: TagConsumer<*> = WebPagePostprocessor(page, this@withSnarkPage.consumer) override val consumer: TagConsumer<*> = WebPagePostprocessor(page, this@withSnarkPage.consumer)
} }

View File

@ -1,37 +0,0 @@
package space.kscience.snark.html
import space.kscience.dataforge.data.DataTreeItem
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.parseAsName
public class SnarkHtmlEnvironmentBuilder {
public val layouts: HashMap<Name, SiteLayout> = HashMap()
public fun layout(name: String, body: context(SiteBuilder) (DataTreeItem<*>) -> Unit) {
layouts[name.parseAsName()] = object : SiteLayout {
context(SiteBuilder) override fun render(item: DataTreeItem<*>) = body(site, item)
}
}
}
//public fun SnarkEnvironment.html(block: SnarkHtmlEnvironmentBuilder.() -> Unit) {
// contract {
// callsInPlace(block, InvocationKind.EXACTLY_ONCE)
// }
//
// val envBuilder = SnarkHtmlEnvironmentBuilder().apply(block)
//
// val plugin = object : AbstractPlugin() {
// val snark by require(SnarkHtmlPlugin)
//
// override val tag: PluginTag = PluginTag("@extension[${hashCode()}]")
//
//
// override fun content(target: String): Map<Name, Any> = when (target) {
// SiteLayout.TYPE -> envBuilder.layouts
// else -> super.content(target)
// }
// }
// registerPlugin(plugin)
//}

View File

@ -8,11 +8,11 @@ import kotlinx.io.readString
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
import org.intellij.markdown.html.HtmlGenerator import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.MarkdownParser import org.intellij.markdown.parser.MarkdownParser
import space.kscience.snark.SnarkIOReader import space.kscience.snark.SnarkReader
import kotlin.reflect.KType import kotlin.reflect.KType
import kotlin.reflect.typeOf import kotlin.reflect.typeOf
public object HtmlReader : SnarkIOReader<HtmlFragment> { public object HtmlReader : SnarkReader<HtmlFragment> {
override val types: Set<String> = setOf("html") override val types: Set<String> = setOf("html")
override fun readFrom(source: String): HtmlFragment = HtmlFragment { override fun readFrom(source: String): HtmlFragment = HtmlFragment {
@ -25,7 +25,7 @@ public object HtmlReader : SnarkIOReader<HtmlFragment> {
override val type: KType = typeOf<HtmlFragment>() override val type: KType = typeOf<HtmlFragment>()
} }
public object MarkdownReader : SnarkIOReader<HtmlFragment> { public object MarkdownReader : SnarkReader<HtmlFragment> {
override val type: KType = typeOf<HtmlFragment>() override val type: KType = typeOf<HtmlFragment>()
override val types: Set<String> = setOf("text/markdown", "md", "markdown") override val types: Set<String> = setOf("text/markdown", "md", "markdown")
@ -46,7 +46,7 @@ public object MarkdownReader : SnarkIOReader<HtmlFragment> {
override fun readFrom(source: Source): HtmlFragment = readFrom(source.readString()) override fun readFrom(source: Source): HtmlFragment = readFrom(source.readString())
public val snarkReader: SnarkIOReader<HtmlFragment> = SnarkIOReader(this, ContentType.parse("text/markdown")) public val snarkReader: SnarkReader<HtmlFragment> = SnarkReader(this, "text/markdown")
} }

View File

@ -0,0 +1,156 @@
package space.kscience.snark.html.static
import kotlinx.html.html
import kotlinx.html.stream.createHTML
import kotlinx.io.asSink
import kotlinx.io.buffered
import space.kscience.dataforge.data.*
import space.kscience.dataforge.io.Binary
import space.kscience.dataforge.io.writeBinary
import space.kscience.dataforge.meta.*
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.isEmpty
import space.kscience.dataforge.names.plus
import space.kscience.dataforge.workspace.FileData
import space.kscience.dataforge.workspace.Workspace
import space.kscience.snark.html.*
import java.nio.file.Path
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.io.path.*
import kotlin.reflect.typeOf
/**
* An implementation of [SiteContext] to render site as a static directory [outputPath]
*/
internal class StaticSiteContext(
override val siteMeta: Meta,
private val baseUrl: String,
override val route: Name,
private val outputPath: Path,
) : SiteContext {
// @OptIn(ExperimentalPathApi::class)
// private suspend fun files(item: DataTreeItem<Any>, routeName: Name) {
// //try using direct file rendering
// item.meta[FileData.FILE_PATH_KEY]?.string?.let {
// val file = Path.of(it)
// val targetPath = outputPath.resolve(routeName.toWebPath())
// targetPath.parent.createDirectories()
// file.copyToRecursively(targetPath, followLinks = false)
// //success, don't do anything else
// return@files
// }
//
// when (item) {
// is DataTreeItem.Leaf -> {
// val datum = item.data
// if (datum.type != typeOf<Binary>()) error("Can't directly serve file of type ${item.data.type}")
// val targetPath = outputPath.resolve(routeName.toWebPath())
// val binary = datum.await() as Binary
// targetPath.outputStream().asSink().buffered().use {
// it.writeBinary(binary)
// }
// }
//
// is DataTreeItem.Node -> {
// item.tree.items.forEach { (token, childItem) ->
// files(childItem, routeName + token)
// }
// }
// }
// }
@OptIn(ExperimentalPathApi::class)
override suspend fun static(route: Name, data: Data<Binary>) {
data.meta[FileData.FILE_PATH_KEY]?.string?.let {
val file = Path.of(it)
val targetPath = outputPath.resolve(route.toWebPath())
targetPath.parent.createDirectories()
file.copyToRecursively(targetPath, followLinks = false)
//success, don't do anything else
return
}
if (data.type != typeOf<Binary>()) error("Can't directly serve file of type ${data.type}")
val targetPath = outputPath.resolve(route.toWebPath())
val binary = data.await()
targetPath.outputStream().asSink().buffered().use {
it.writeBinary(binary)
}
}
private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
ref
} else if (ref.isEmpty()) {
baseUrl
} else {
"${baseUrl.removeSuffix("/")}/$ref"
}
inner class StaticPageContext(override val pageMeta: Meta) : PageContext {
override fun resolveRef(ref: String): String =
this@StaticSiteContext.resolveRef(this@StaticSiteContext.baseUrl, ref)
override fun resolvePageRef(pageName: Name, relative: Boolean): String = resolveRef(
(if (relative) this@StaticSiteContext.route + pageName else pageName).toWebPath() + ".html"
)
}
override suspend fun page(route: Name, data: DataSet<Any>, pageMeta: Meta, htmlPage: HtmlPage) {
val htmlBuilder = createHTML()
val modifiedPageMeta = pageMeta.toMutableMeta().apply {
"name" put route.toString()
}
htmlBuilder.html {
with(htmlPage) {
renderPage(StaticPageContext(Laminate(modifiedPageMeta, siteMeta)), data)
}
}
val newPath = if (route.isEmpty()) {
outputPath.resolve("index.html")
} else {
outputPath.resolve(route.toWebPath() + ".html")
}
newPath.parent.createDirectories()
newPath.writeText(htmlBuilder.finalize())
}
override suspend fun site(route: Name, data: DataSet<Any>, siteMeta: Meta, htmlSite: HtmlSite) {
val subSiteContext = StaticSiteContext(
siteMeta = Laminate(siteMeta, this.siteMeta),
baseUrl = if (baseUrl == "") "" else resolveRef(baseUrl, route.toWebPath()),
route = Name.EMPTY,
outputPath = outputPath.resolve(route.toWebPath())
)
htmlSite.renderSite(subSiteContext, data)
}
}
/**
* 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.
*
*/
public fun SnarkHtml.staticSite(
data: DataSet<*>,
outputPath: Path,
siteUrl: String = outputPath.absolutePathString().replace("\\", "/"),
siteMeta: Meta = data.meta,
block: SiteContext.() -> Unit,
) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
StaticSiteContext(siteMeta, siteUrl, Name.EMPTY, outputPath).block()
}

View File

@ -10,21 +10,16 @@ import io.ktor.server.html.respondHtml
import io.ktor.server.http.content.staticFiles import io.ktor.server.http.content.staticFiles
import io.ktor.server.plugins.origin import io.ktor.server.plugins.origin
import io.ktor.server.response.respondBytes import io.ktor.server.response.respondBytes
import io.ktor.server.routing.Route import io.ktor.server.routing.*
import io.ktor.server.routing.createRouteFromPath
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.css.CssBuilder import kotlinx.css.CssBuilder
import kotlinx.html.CommonAttributeGroupFacade import kotlinx.html.CommonAttributeGroupFacade
import kotlinx.html.HTML
import kotlinx.html.head import kotlinx.html.head
import kotlinx.html.style import kotlinx.html.style
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.ContextAware
import space.kscience.dataforge.context.error import space.kscience.dataforge.context.error
import space.kscience.dataforge.context.logger import space.kscience.dataforge.context.logger
import space.kscience.dataforge.data.DataTree import space.kscience.dataforge.data.*
import space.kscience.dataforge.data.DataTreeItem
import space.kscience.dataforge.data.await
import space.kscience.dataforge.data.getItem
import space.kscience.dataforge.io.Binary import space.kscience.dataforge.io.Binary
import space.kscience.dataforge.io.toByteArray import space.kscience.dataforge.io.toByteArray
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
@ -33,10 +28,7 @@ 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.dataforge.names.plus
import space.kscience.dataforge.workspace.FileData import space.kscience.dataforge.workspace.FileData
import space.kscience.snark.html.SiteBuilder import space.kscience.snark.html.*
import space.kscience.snark.html.SnarkHtml
import space.kscience.snark.html.WebPage
import space.kscience.snark.html.toWebPath
import java.nio.file.Path import java.nio.file.Path
import kotlin.contracts.InvocationKind import kotlin.contracts.InvocationKind
import kotlin.contracts.contract import kotlin.contracts.contract
@ -47,17 +39,16 @@ public fun CommonAttributeGroupFacade.css(block: CssBuilder.() -> Unit) {
} }
public class KtorSiteBuilder( public class KtorSiteBuilder(
override val snark: SnarkHtml, override val context: Context,
override val data: DataTree<*>,
override val siteMeta: Meta, override val siteMeta: Meta,
private val baseUrl: String, private val baseUrl: String,
override val route: Name, override val route: Name,
private val ktorRoute: Route, private val ktorRoute: Route,
) : SiteBuilder { ) : SiteContext, ContextAware {
private fun files(item: DataTreeItem<Any>, routeName: Name) {
//try using direct file rendering override suspend fun static(route: Name, data: Data<Binary>) {
item.meta[FileData.FILE_PATH_KEY]?.string?.let { data.meta[FileData.FILE_PATH_KEY]?.string?.let {
val file = try { val file = try {
Path.of(it).toFile() Path.of(it).toFile()
} catch (ex: Exception) { } catch (ex: Exception) {
@ -66,62 +57,27 @@ public class KtorSiteBuilder(
return@let return@let
} }
val fileName = routeName.toWebPath() val fileName = route.toWebPath()
ktorRoute.staticFiles(fileName, file) ktorRoute.staticFiles(fileName, file)
//success, don't do anything else //success, don't do anything else
return@files return
} }
when (item) {
is DataTreeItem.Leaf -> {
val datum = item.data
if (datum.type != typeOf<Binary>()) error("Can't directly serve file of type ${item.data.type}")
ktorRoute.get(routeName.toWebPath()) {
val binary = datum.await() as Binary
val extension = item.meta[FileData.FILE_EXTENSION_KEY]?.string?.let { ".$it" } ?: ""
val contentType: ContentType = extension
.let(ContentType::fromFileExtension)
.firstOrNull()
?: ContentType.Any
call.respondBytes(contentType = contentType) {
//TODO optimize using streaming
binary.toByteArray()
}
}
}
is DataTreeItem.Node -> { if (data.type != typeOf<Binary>()) error("Can't directly serve file of type ${data.type}")
item.tree.items.forEach { (token, childItem) -> ktorRoute.get(route.toWebPath()) {
files(childItem, routeName + token) val binary = data.await()
} val extension = data.meta[FileData.FILE_EXTENSION_KEY]?.string?.let { ".$it" } ?: ""
val contentType: ContentType = extension
.let(ContentType::fromFileExtension)
.firstOrNull()
?: ContentType.Any
call.respondBytes(contentType = contentType) {
//TODO optimize using streaming
binary.toByteArray()
} }
} }
} }
override fun static(dataName: Name, routeName: Name) {
val item: DataTreeItem<Any> = data.getItem(dataName) ?: error("Data with name $dataName is not resolved")
files(item, routeName)
}
//
// override fun file(file: Path, webPath: String) {
// if (file.isDirectory()) {
// ktorRoute.static(webPath) {
// //TODO check non-standard FS and convert
// files(file.toFile())
// }
// } else if (webPath.isBlank()) {
// error("Can't mount file to an empty route")
// } else {
// ktorRoute.file(webPath, file.toFile())
// }
// }
// override fun file(dataName: Name, webPath: String) {
// val fileData = data[dataName]
// if(fileData is FileData){
// ktorRoute.file(webPath)
// }
// }
private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) { private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
ref ref
} else if (ref.isEmpty()) { } else if (ref.isEmpty()) {
@ -131,12 +87,10 @@ public class KtorSiteBuilder(
} }
private inner class KtorWebPage( private inner class KtorPageContext(
val pageBaseUrl: String, val pageBaseUrl: String,
override val pageMeta: Meta, override val pageMeta: Meta,
) : WebPage { ) : PageContext {
override val snark: SnarkHtml get() = this@KtorSiteBuilder.snark
override val data: DataTree<*> get() = this@KtorSiteBuilder.data
override fun resolveRef(ref: String): String = this@KtorSiteBuilder.resolveRef(pageBaseUrl, ref) override fun resolveRef(ref: String): String = this@KtorSiteBuilder.resolveRef(pageBaseUrl, ref)
@ -145,7 +99,7 @@ public class KtorSiteBuilder(
relative: Boolean, relative: Boolean,
): String { ): String {
val fullPageName = if (relative) this@KtorSiteBuilder.route + pageName else pageName val fullPageName = if (relative) this@KtorSiteBuilder.route + pageName else pageName
return if (fullPageName.endsWith(SiteBuilder.INDEX_PAGE_TOKEN)) { return if (fullPageName.endsWith(SiteContext.INDEX_PAGE_TOKEN)) {
resolveRef(fullPageName.cutLast().toWebPath()) resolveRef(fullPageName.cutLast().toWebPath())
} else { } else {
resolveRef(fullPageName.toWebPath()) resolveRef(fullPageName.toWebPath())
@ -153,7 +107,7 @@ public class KtorSiteBuilder(
} }
} }
override fun page(route: Name, pageMeta: Meta, content: context(HTML, WebPage) () -> Unit) { override suspend fun page(route: Name, data: DataSet<Any>, pageMeta: Meta, htmlPage: HtmlPage) {
ktorRoute.get(route.toWebPath()) { ktorRoute.get(route.toWebPath()) {
val request = call.request val request = call.request
//substitute host for url for backwards calls //substitute host for url for backwards calls
@ -167,53 +121,33 @@ public class KtorSiteBuilder(
"name" put route.toString() "name" put route.toString()
"url" put url.buildString() "url" put url.buildString()
} }
val pageBuilder = KtorWebPage(url.buildString(), Laminate(modifiedPageMeta, siteMeta)) val pageBuilder = KtorPageContext(url.buildString(), Laminate(modifiedPageMeta, siteMeta))
call.respondHtml { call.respondHtml {
head{} head {}
content(this, pageBuilder) with(htmlPage) {
renderPage(pageBuilder, data)
}
} }
} }
} }
override fun route( override suspend fun site(route: Name, data: DataSet<Any>, siteMeta: Meta, htmlSite: HtmlSite) {
routeName: Name, val context = KtorSiteBuilder(
dataOverride: DataTree<*>?, context,
routeMeta: Meta, siteMeta = Laminate(siteMeta, this.siteMeta),
): SiteBuilder = KtorSiteBuilder( baseUrl = resolveRef(baseUrl, route.toWebPath()),
snark = snark, route = Name.EMPTY,
data = dataOverride ?: data, ktorRoute = ktorRoute.createRouteFromPath(route.toWebPath())
siteMeta = Laminate(routeMeta, siteMeta), )
baseUrl = baseUrl,
route = this.route + routeName,
ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath())
)
override fun site( htmlSite.renderSite(context, data)
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())
)
//
// override fun resourceFile(resourcesPath: String, webPath: String) {
// ktorRoute.resource(resourcesPath, resourcesPath)
// }
// override fun resourceDirectory(resourcesPath: String) {
// ktorRoute.resources(resourcesPath)
// }
} }
private fun Route.site( private fun Route.site(
snarkHtmlPlugin: SnarkHtml, context: Context,
data: DataTree<*>, data: DataTree<*>,
baseUrl: String = "", baseUrl: String = "",
siteMeta: Meta = data.meta, siteMeta: Meta = data.meta,
@ -222,20 +156,20 @@ private fun Route.site(
contract { contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE) callsInPlace(block, InvocationKind.EXACTLY_ONCE)
} }
block(KtorSiteBuilder(snarkHtmlPlugin, data, siteMeta, baseUrl, route = Name.EMPTY, this@Route)) block(KtorSiteBuilder(context, siteMeta, baseUrl, route = Name.EMPTY, this@Route))
} }
public fun Application.site( public fun Application.site(
snark: SnarkHtml, context: Context,
data: DataTree<*>, data: DataTree<*>,
baseUrl: String = "", baseUrl: String = "",
siteMeta: Meta = data.meta, siteMeta: Meta = data.meta,
block: SiteBuilder.() -> Unit, block: SiteContext.() -> Unit,
) { ) {
contract { contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE) callsInPlace(block, InvocationKind.EXACTLY_ONCE)
} }
routing { routing {
site(snark, data, baseUrl, siteMeta, block) site(context, data, baseUrl, siteMeta, block)
} }
} }