From 738f41265fb189a28e9bfb89216aa22ae898ec77 Mon Sep 17 00:00:00 2001 From: Alexander Nozik Date: Thu, 30 Nov 2023 22:04:13 +0300 Subject: [PATCH] Full refactoring --- .../kotlin/space/kscience/snark/Snark.kt | 6 +- .../{SnarkIOReader.kt => SnarkReader.kt} | 14 +- .../space/kscience/snark/html/DataRenderer.kt | 48 ---- .../html/{HtmlData.kt => HtmlFragment.kt} | 4 +- .../space/kscience/snark/html/HtmlPage.kt | 45 ++++ .../space/kscience/snark/html/HtmlSite.kt | 36 +++ .../space/kscience/snark/html/Language.kt | 136 +++++----- .../snark/html/{WebPage.kt => PageContext.kt} | 26 +- .../space/kscience/snark/html/SiteBuilder.kt | 248 ------------------ .../space/kscience/snark/html/SiteContext.kt | 177 +++++++++++++ .../space/kscience/snark/html/SiteLayout.kt | 32 --- .../space/kscience/snark/html/SnarkHtml.kt | 54 +--- .../kscience/snark/html/StaticSiteBuilder.kt | 200 -------------- .../snark/html/WebPagePostprocessor.kt | 29 +- .../kscience/snark/html/htmlEnvironment.kt | 37 --- .../space/kscience/snark/html/readers.kt | 8 +- .../snark/html/static/StaticSiteContext.kt | 156 +++++++++++ .../kscience/snark/ktor/KtorSiteBuilder.kt | 160 ++++------- 18 files changed, 579 insertions(+), 837 deletions(-) rename snark-core/src/commonMain/kotlin/space/kscience/snark/{SnarkIOReader.kt => SnarkReader.kt} (76%) delete mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/DataRenderer.kt rename snark-html/src/jvmMain/kotlin/space/kscience/snark/html/{HtmlData.kt => HtmlFragment.kt} (91%) create mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlPage.kt create mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlSite.kt rename snark-html/src/jvmMain/kotlin/space/kscience/snark/html/{WebPage.kt => PageContext.kt} (78%) delete mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteBuilder.kt create mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteContext.kt delete mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteLayout.kt delete mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt delete mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/htmlEnvironment.kt create mode 100644 snark-html/src/jvmMain/kotlin/space/kscience/snark/html/static/StaticSiteContext.kt diff --git a/snark-core/src/commonMain/kotlin/space/kscience/snark/Snark.kt b/snark-core/src/commonMain/kotlin/space/kscience/snark/Snark.kt index f67e310..524fef4 100644 --- a/snark-core/src/commonMain/kotlin/space/kscience/snark/Snark.kt +++ b/snark-core/src/commonMain/kotlin/space/kscience/snark/Snark.kt @@ -21,8 +21,8 @@ public class Snark : WorkspacePlugin() { public val io: IOPlugin by require(IOPlugin) override val tag: PluginTag get() = Companion.tag - public val readers: Map> by lazy { - context.gather>(SnarkIOReader.DF_TYPE, inherit = true) + public val readers: Map> by lazy { + context.gather>(SnarkReader.DF_TYPE, inherit = true) } /** @@ -50,7 +50,7 @@ public class Snark : WorkspacePlugin() { readByteArray() } - internal val byteArraySnarkParser = SnarkIOReader(byteArrayIOReader) + internal val byteArraySnarkParser = SnarkReader(byteArrayIOReader) } } \ No newline at end of file diff --git a/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkIOReader.kt b/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkReader.kt similarity index 76% rename from snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkIOReader.kt rename to snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkReader.kt index 7fb293f..c31bb24 100644 --- a/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkIOReader.kt +++ b/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkReader.kt @@ -3,11 +3,11 @@ package space.kscience.snark import space.kscience.dataforge.io.IOReader import space.kscience.dataforge.io.asBinary import space.kscience.dataforge.misc.DfId -import space.kscience.snark.SnarkIOReader.Companion.DEFAULT_PRIORITY -import space.kscience.snark.SnarkIOReader.Companion.DF_TYPE +import space.kscience.snark.SnarkReader.Companion.DEFAULT_PRIORITY +import space.kscience.snark.SnarkReader.Companion.DF_TYPE @DfId(DF_TYPE) -public interface SnarkIOReader: IOReader { +public interface SnarkReader: IOReader { public val types: Set public val priority: Int get() = DEFAULT_PRIORITY public fun readFrom(source: String): T @@ -27,17 +27,17 @@ public interface SnarkIOReader: IOReader { * @property priority The priority of the SnarkIOReader. Higher priority SnarkIOReader instances will be preferred over lower priority ones. */ -private class SnarkIOReaderWrapper( +private class SnarkReaderWrapper( private val reader: IOReader, override val types: Set, override val priority: Int = DEFAULT_PRIORITY, -) : IOReader by reader, SnarkIOReader { +) : IOReader by reader, SnarkReader { override fun readFrom(source: String): T = readFrom(source.encodeToByteArray().asBinary()) } -public fun SnarkIOReader( +public fun SnarkReader( reader: IOReader, vararg types: String, priority: Int = DEFAULT_PRIORITY -): SnarkIOReader = SnarkIOReaderWrapper(reader, types.toSet(), priority) \ No newline at end of file +): SnarkReader = SnarkReaderWrapper(reader, types.toSet(), priority) \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/DataRenderer.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/DataRenderer.kt deleted file mode 100644 index bb9f2e9..0000000 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/DataRenderer.kt +++ /dev/null @@ -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) - - public companion object { - public val DEFAULT: DataRenderer = object : DataRenderer { - - context(SiteBuilder) - override fun invoke(name: Name, data: Data) { - if (data.type == typeOf()) { - 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) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlData.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlFragment.kt similarity index 91% rename from snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlData.kt rename to snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlFragment.kt index cea0466..aa1a872 100644 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlData.kt +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlFragment.kt @@ -20,8 +20,8 @@ public fun interface HtmlFragment { public typealias HtmlData = Data -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) { with(data.await()) { consumer.renderFragment() } } diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlPage.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlPage.kt new file mode 100644 index 0000000..ce09d8c --- /dev/null +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlPage.kt @@ -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.page(name: Name, pageMeta: Meta = Meta.EMPTY, block: HTML.(pageContext: PageContext, pageData: DataSet) -> Unit) { + val page = HtmlPage(block) + static(name, page, pageMeta) +} + + + +// if (data.type == typeOf()) { +// 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) +// } +// } +// } \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlSite.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlSite.kt new file mode 100644 index 0000000..3a048e4 --- /dev/null +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/HtmlSite.kt @@ -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) +} + +public fun DataSetBuilder.site( + name: Name, + siteMeta: Meta, + block: (siteContext: SiteContext, siteData: DataSet) -> Unit, +) { + static(name, HtmlSite(block), siteMeta) +} + +//public fun DataSetBuilder.site(name: Name, block: DataSetBuilder.() -> Unit) { +// node(name, block) +//} + +internal fun DataSetBuilder.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) + } +} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/Language.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/Language.kt index 3dd1893..7c45e92 100644 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/Language.kt +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/Language.kt @@ -1,6 +1,7 @@ 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.names.* 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 - public class Language : Scheme() { /** * Language key override @@ -35,49 +35,50 @@ public class Language : Scheme() { 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" - - /** - * Automatically build a language map for a data piece with given [name] based on existence of appropriate data nodes. - */ - context(SiteBuilder) - public fun forName(name: Name): Meta = Meta { - val currentLanguagePrefix = languages[language]?.get(Language::prefix.name)?.string ?: language - val fullName = (route.removeFirstOrNull(currentLanguagePrefix.asName()) ?: route) + name - languages.forEach { (key, meta) -> - val languagePrefix: String = meta[Language::prefix.name].string ?: key - val nameWithLanguage: Name = if (languagePrefix.isBlank()) { - fullName - } else { - languagePrefix.asName() + fullName - } - if (data.getItem(name) != null) { - key put meta.asMutableMeta().apply { - Language::target.name put nameWithLanguage.toString() - } - } - } - } +// +// /** +// * Automatically build a language map for a data piece with given [name] based on existence of appropriate data nodes. +// */ +// context(SiteContext) +// public fun forName(name: Name): Meta = Meta { +// val currentLanguagePrefix = languages[language]?.get(Language::prefix.name)?.string ?: language +// val fullName = (route.removeFirstOrNull(currentLanguagePrefix.asName()) ?: route) + name +// languages.forEach { (key, meta) -> +// val languagePrefix: String = meta[Language::prefix.name].string ?: key +// val nameWithLanguage: Name = if (languagePrefix.isBlank()) { +// fullName +// } else { +// languagePrefix.asName() + fullName +// } +// if (resolveData.getItem(name) != null) { +// key put meta.asMutableMeta().apply { +// Language::target.name put nameWithLanguage.toString() +// } +// } +// } +// } } } -public val SiteBuilder.languages: Map +public val SiteContext.languages: Map 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 -public val SiteBuilder.languagePrefix: Name +public val SiteContext.languagePrefix: Name get() = languages[language]?.let { it[Language::prefix.name].string ?: language }?.parseAsName() ?: Name.EMPTY -public fun SiteBuilder.withLanguages(languageMap: Map, block: SiteBuilder.(language: String) -> Unit) { +@SnarkBuilder +public suspend fun SiteContext.multiLanguageSite(data: DataSet, languageMap: Map, site: HtmlSite) { languageMap.forEach { (languageKey, languageMeta) -> val prefix = languageMeta[Language::prefix.name].string ?: languageKey - val routeMeta = Meta { + val languageSiteMeta = Meta { SITE_LANGUAGE_KEY put languageKey SITE_LANGUAGES_KEY put Meta { languageMap.forEach { @@ -85,65 +86,48 @@ public fun SiteBuilder.withLanguages(languageMap: Map, block: Site } } } - route(prefix, routeMeta = routeMeta) { - block(languageKey) - } + site(prefix.parseAsName(), data.branch(prefix), siteMeta = Laminate(languageSiteMeta, siteMeta), site) } } -@SnarkBuilder -public fun SiteBuilder.withLanguages( - vararg language: Pair, - 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 */ -public val WebPage.language: String +public val PageContext.language: String 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 */ -public val WebPage.languages: Map +public val PageContext.languages: Map 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 return resolvePageRef(prefix + pageName, relative) } - -/** - * Render all pages in a node with given name. Use localization prefix if appropriate data is available. - */ -public fun SiteBuilder.localizedPages( - dataPath: Name, - remotePath: Name = dataPath, - dataRenderer: DataRenderer = DataRenderer.DEFAULT, -) { - val item = data.getItem(languagePrefix + dataPath) - ?: data.getItem(dataPath) - ?: error("No data found by name $dataPath") - route(remotePath) { - pages(item, dataRenderer) - } -} - -public fun SiteBuilder.localizedPages( - dataPath: String, - remotePath: Name = dataPath.parseAsName(), - dataRenderer: DataRenderer = DataRenderer.DEFAULT, -) { - localizedPages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer) -} \ No newline at end of file +// +///** +// * Render all pages in a node with given name. Use localization prefix if appropriate data is available. +// */ +//public fun SiteContext.localizedPages( +// dataPath: Name, +// remotePath: Name = dataPath, +// dataRenderer: DataRenderer = DataRenderer.DEFAULT, +//) { +// val item = resolveData.getItem(languagePrefix + dataPath) +// ?: resolveData.getItem(dataPath) +// ?: error("No data found by name $dataPath") +// route(remotePath) { +// pages(item, dataRenderer) +// } +//} +// +//public fun SiteContext.localizedPages( +// dataPath: String, +// remotePath: Name = dataPath.parseAsName(), +// dataRenderer: DataRenderer = DataRenderer.DEFAULT, +//) { +// localizedPages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer) +//} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/WebPage.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/PageContext.kt similarity index 78% rename from snark-html/src/jvmMain/kotlin/space/kscience/snark/html/WebPage.kt rename to snark-html/src/jvmMain/kotlin/space/kscience/snark/html/PageContext.kt index c9447e4..21e1549 100644 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/WebPage.kt +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/PageContext.kt @@ -1,5 +1,6 @@ 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.* @@ -10,7 +11,6 @@ import space.kscience.dataforge.names.* import space.kscience.snark.SnarkBuilder import space.kscience.snark.SnarkContext -context(SnarkContext) public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") { if (it.hasIndex()) { "${it.body}[${it.index}]" @@ -23,13 +23,7 @@ public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") { * A context for building a single page */ @SnarkBuilder -public interface WebPage : ContextAware, SnarkContext { - - public val snark: SnarkHtml - - override val context: Context get() = snark.context - - public val data: DataTree<*> +public interface PageContext : SnarkContext { /** * 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]. * - * @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 } -context(WebPage) -public val page: WebPage - get() = this@WebPage +context(PageContext) +public val page: PageContext + 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 */ context(SnarkContext) public fun DataTree<*>.resolveHtmlOrNull(name: Name): HtmlData? { - val resolved = (getByType(name) ?: getByType(name + SiteBuilder.INDEX_PAGE_TOKEN)) + val resolved = (getByType(name) ?: getByType(name + SiteContext.INDEX_PAGE_TOKEN)) return resolved?.takeIf { it.published //TODO add language confirmation diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteBuilder.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteBuilder.kt deleted file mode 100644 index 12b56e5..0000000 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteBuilder.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteContext.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteContext.kt new file mode 100644 index 0000000..4a8e3f7 --- /dev/null +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteContext.kt @@ -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) + + + /** + * 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, + 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, + 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, + pageMeta: Meta = Meta.EMPTY, + htmlPage: HTML.(page: PageContext, data: DataSet) -> Unit, +): Unit = page(route, data, pageMeta, HtmlPage(htmlPage)) + +context(SiteContext) +public val site: SiteContext + get() = this@SiteContext + + +public suspend fun SiteContext.renderPages(data: DataSet): Unit { + + // Render all sub-sites + data.filterByType().forEach { siteData: NamedData -> + // 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().forEach { pageData: NamedData -> + 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) +//} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteLayout.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteLayout.kt deleted file mode 100644 index 807422d..0000000 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SiteLayout.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SnarkHtml.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SnarkHtml.kt index ae52cde..294a7af 100644 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SnarkHtml.kt +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/SnarkHtml.kt @@ -6,7 +6,6 @@ import io.ktor.http.ContentType import kotlinx.io.readByteArray import space.kscience.dataforge.context.* import space.kscience.dataforge.data.* -import space.kscience.dataforge.io.Binary import space.kscience.dataforge.io.IOPlugin import space.kscience.dataforge.io.IOReader import space.kscience.dataforge.io.JsonMetaFormat @@ -21,18 +20,12 @@ import space.kscience.dataforge.provider.dfId import space.kscience.dataforge.workspace.* import space.kscience.snark.ImageIOReader import space.kscience.snark.Snark -import space.kscience.snark.SnarkIOReader +import space.kscience.snark.SnarkReader import space.kscience.snark.TextProcessor import java.net.URLConnection import kotlin.io.path.Path import kotlin.io.path.extension -public fun SnarkIOReader( - reader: IOReader, - vararg types: ContentType, - priority: Int = SnarkIOReader.DEFAULT_PRIORITY, -): SnarkIOReader = SnarkIOReader(reader, *types.map { it.toString() }.toTypedArray(), priority = priority) - /** * A plugin used for rendering a [DataTree] as HTML @@ -44,33 +37,17 @@ public class SnarkHtml : WorkspacePlugin() { 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 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 = when (target) { - SnarkIOReader::class.dfId -> mapOf( + SnarkReader::class.dfId -> mapOf( "html".asName() to HtmlReader, "markdown".asName() to MarkdownReader, - "json".asName() to SnarkIOReader(JsonMetaFormat, ContentType.Application.Json), - "yaml".asName() to SnarkIOReader(YamlMetaFormat, "text/yaml", "yaml"), - "png".asName() to SnarkIOReader(ImageIOReader, ContentType.Image.PNG), - "jpg".asName() to SnarkIOReader(ImageIOReader, ContentType.Image.JPEG), - "gif".asName() to SnarkIOReader(ImageIOReader, ContentType.Image.GIF), - "svg".asName() to SnarkIOReader(IOReader.binary, ContentType.Image.SVG, ContentType.parse("svg")), - "raw".asName() to SnarkIOReader( + "json".asName() to SnarkReader(JsonMetaFormat, ContentType.Application.Json.toString()), + "yaml".asName() to SnarkReader(YamlMetaFormat, "text/yaml", "yaml"), + "png".asName() to SnarkReader(ImageIOReader, ContentType.Image.PNG.toString()), + "jpg".asName() to SnarkReader(ImageIOReader, ContentType.Image.JPEG.toString()), + "gif".asName() to SnarkReader(ImageIOReader, ContentType.Image.GIF.toString()), + "svg".asName() to SnarkReader(IOReader.binary, ContentType.Image.SVG.toString(), "svg"), + "raw".asName() to SnarkReader( IOReader.binary, "css", "js", @@ -86,13 +63,6 @@ public class SnarkHtml : WorkspacePlugin() { else -> super.content(target) } -// public val assets: TaskReference by task { -// node(Name.EMPTY, from(allData).filter { name, meta -> -// -// }) -// } - - public val preprocess: TaskReference by task { pipeFrom(dataByType()) { text, _, meta -> meta[TextProcessor.TEXT_TRANSFORMATION_KEY]?.let { @@ -128,6 +98,10 @@ public class SnarkHtml : WorkspacePlugin() { } +// public val site by task { +// +// } + // public val textTransformationAction: Action = Action.map { // val transformations = actionMeta.getIndexed("transformation").entries.sortedBy { // it.key?.toIntOrNull() ?: 0 @@ -144,7 +118,7 @@ public class SnarkHtml : WorkspacePlugin() { readByteArray() } - internal val byteArraySnarkParser = SnarkIOReader(byteArrayIOReader) + internal val byteArraySnarkParser = SnarkReader(byteArrayIOReader) } } diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt deleted file mode 100644 index e71356c..0000000 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt +++ /dev/null @@ -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, 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()) 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 = 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() -} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/WebPagePostprocessor.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/WebPagePostprocessor.kt index 6c64ad3..947d6c5 100644 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/WebPagePostprocessor.kt +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/WebPagePostprocessor.kt @@ -1,22 +1,19 @@ package space.kscience.snark.html -import kotlinx.html.A -import kotlinx.html.FlowContent -import kotlinx.html.Tag -import kotlinx.html.TagConsumer +import kotlinx.html.* import space.kscience.dataforge.meta.string import space.kscience.dataforge.names.parseAsName 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() /** * A basic [TextProcessor] that replaces `${...}` expressions in text. The following expressions are recognised: * * `homeRef` resolves to [homeRef] - * * `resolveRef("...")` -> [WebPage.resolveRef] - * * `resolvePageRef("...")` -> [WebPage.resolvePageRef] - * * `pageMeta.get("...") -> [WebPage.pageMeta] get string method + * * `resolveRef("...")` -> [PageContext.resolveRef] + * * `resolvePageRef("...")` -> [PageContext.resolvePageRef] + * * `pageMeta.get("...") -> [PageContext.pageMeta] get string method * Otherwise return unchanged string */ 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( - public val page: WebPage, + public val page: PageContext, private val consumer: TagConsumer, ) : TagConsumer by consumer { @@ -64,9 +60,20 @@ public class WebPagePostprocessor( override fun onTagContent(content: CharSequence) { 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 { override val consumer: TagConsumer<*> = WebPagePostprocessor(page, this@withSnarkPage.consumer) } diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/htmlEnvironment.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/htmlEnvironment.kt deleted file mode 100644 index 0772f20..0000000 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/htmlEnvironment.kt +++ /dev/null @@ -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 = 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 = when (target) { -// SiteLayout.TYPE -> envBuilder.layouts -// else -> super.content(target) -// } -// } -// registerPlugin(plugin) -//} \ No newline at end of file diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/readers.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/readers.kt index 2dc1ad0..0623a2f 100644 --- a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/readers.kt +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/readers.kt @@ -8,11 +8,11 @@ import kotlinx.io.readString import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor import org.intellij.markdown.html.HtmlGenerator import org.intellij.markdown.parser.MarkdownParser -import space.kscience.snark.SnarkIOReader +import space.kscience.snark.SnarkReader import kotlin.reflect.KType import kotlin.reflect.typeOf -public object HtmlReader : SnarkIOReader { +public object HtmlReader : SnarkReader { override val types: Set = setOf("html") override fun readFrom(source: String): HtmlFragment = HtmlFragment { @@ -25,7 +25,7 @@ public object HtmlReader : SnarkIOReader { override val type: KType = typeOf() } -public object MarkdownReader : SnarkIOReader { +public object MarkdownReader : SnarkReader { override val type: KType = typeOf() override val types: Set = setOf("text/markdown", "md", "markdown") @@ -46,7 +46,7 @@ public object MarkdownReader : SnarkIOReader { override fun readFrom(source: Source): HtmlFragment = readFrom(source.readString()) - public val snarkReader: SnarkIOReader = SnarkIOReader(this, ContentType.parse("text/markdown")) + public val snarkReader: SnarkReader = SnarkReader(this, "text/markdown") } diff --git a/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/static/StaticSiteContext.kt b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/static/StaticSiteContext.kt new file mode 100644 index 0000000..c9684c2 --- /dev/null +++ b/snark-html/src/jvmMain/kotlin/space/kscience/snark/html/static/StaticSiteContext.kt @@ -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, 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()) 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) { + 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()) 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, 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, 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() +} \ 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 b322ff9..972e87b 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 @@ -10,21 +10,16 @@ import io.ktor.server.html.respondHtml import io.ktor.server.http.content.staticFiles import io.ktor.server.plugins.origin import io.ktor.server.response.respondBytes -import io.ktor.server.routing.Route -import io.ktor.server.routing.createRouteFromPath -import io.ktor.server.routing.get -import io.ktor.server.routing.routing +import io.ktor.server.routing.* import kotlinx.css.CssBuilder import kotlinx.html.CommonAttributeGroupFacade -import kotlinx.html.HTML import kotlinx.html.head 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.logger -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.data.* import space.kscience.dataforge.io.Binary import space.kscience.dataforge.io.toByteArray 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.plus import space.kscience.dataforge.workspace.FileData -import space.kscience.snark.html.SiteBuilder -import space.kscience.snark.html.SnarkHtml -import space.kscience.snark.html.WebPage -import space.kscience.snark.html.toWebPath +import space.kscience.snark.html.* import java.nio.file.Path import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -47,17 +39,16 @@ public fun CommonAttributeGroupFacade.css(block: CssBuilder.() -> Unit) { } public class KtorSiteBuilder( - override val snark: SnarkHtml, - override val data: DataTree<*>, + override val context: Context, override val siteMeta: Meta, private val baseUrl: String, override val route: Name, private val ktorRoute: Route, -) : SiteBuilder { +) : SiteContext, ContextAware { - private fun files(item: DataTreeItem, routeName: Name) { - //try using direct file rendering - item.meta[FileData.FILE_PATH_KEY]?.string?.let { + + override suspend fun static(route: Name, data: Data) { + data.meta[FileData.FILE_PATH_KEY]?.string?.let { val file = try { Path.of(it).toFile() } catch (ex: Exception) { @@ -66,62 +57,27 @@ public class KtorSiteBuilder( return@let } - val fileName = routeName.toWebPath() + val fileName = route.toWebPath() ktorRoute.staticFiles(fileName, file) //success, don't do anything else - return@files + return } - when (item) { - is DataTreeItem.Leaf -> { - val datum = item.data - if (datum.type != typeOf()) 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 -> { - item.tree.items.forEach { (token, childItem) -> - files(childItem, routeName + token) - } + if (data.type != typeOf()) error("Can't directly serve file of type ${data.type}") + ktorRoute.get(route.toWebPath()) { + 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 = 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()) { ref } else if (ref.isEmpty()) { @@ -131,12 +87,10 @@ public class KtorSiteBuilder( } - private inner class KtorWebPage( + private inner class KtorPageContext( val pageBaseUrl: String, override val pageMeta: Meta, - ) : WebPage { - override val snark: SnarkHtml get() = this@KtorSiteBuilder.snark - override val data: DataTree<*> get() = this@KtorSiteBuilder.data + ) : PageContext { override fun resolveRef(ref: String): String = this@KtorSiteBuilder.resolveRef(pageBaseUrl, ref) @@ -145,7 +99,7 @@ public class KtorSiteBuilder( relative: Boolean, ): String { 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()) } else { 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, pageMeta: Meta, htmlPage: HtmlPage) { ktorRoute.get(route.toWebPath()) { val request = call.request //substitute host for url for backwards calls @@ -167,53 +121,33 @@ public class KtorSiteBuilder( "name" put route.toString() "url" put url.buildString() } - val pageBuilder = KtorWebPage(url.buildString(), Laminate(modifiedPageMeta, siteMeta)) + val pageBuilder = KtorPageContext(url.buildString(), Laminate(modifiedPageMeta, siteMeta)) call.respondHtml { - head{} - content(this, pageBuilder) + head {} + with(htmlPage) { + renderPage(pageBuilder, data) + } } } } - override fun route( - routeName: Name, - dataOverride: DataTree<*>?, - routeMeta: Meta, - ): SiteBuilder = KtorSiteBuilder( - snark = snark, - data = dataOverride ?: data, - siteMeta = Laminate(routeMeta, siteMeta), - baseUrl = baseUrl, - route = this.route + routeName, - ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath()) - ) + override suspend fun site(route: Name, data: DataSet, siteMeta: Meta, htmlSite: HtmlSite) { + val context = KtorSiteBuilder( + context, + siteMeta = Laminate(siteMeta, this.siteMeta), + baseUrl = resolveRef(baseUrl, route.toWebPath()), + route = Name.EMPTY, + ktorRoute = ktorRoute.createRouteFromPath(route.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()) - ) + htmlSite.renderSite(context, data) + } -// -// override fun resourceFile(resourcesPath: String, webPath: String) { -// ktorRoute.resource(resourcesPath, resourcesPath) -// } - -// override fun resourceDirectory(resourcesPath: String) { -// ktorRoute.resources(resourcesPath) -// } } private fun Route.site( - snarkHtmlPlugin: SnarkHtml, + context: Context, data: DataTree<*>, baseUrl: String = "", siteMeta: Meta = data.meta, @@ -222,20 +156,20 @@ private fun Route.site( contract { 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( - snark: SnarkHtml, + context: Context, data: DataTree<*>, baseUrl: String = "", siteMeta: Meta = data.meta, - block: SiteBuilder.() -> Unit, + block: SiteContext.() -> Unit, ) { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } routing { - site(snark, data, baseUrl, siteMeta, block) + site(context, data, baseUrl, siteMeta, block) } }