diff options
Diffstat (limited to 'plugins/base/src/main/kotlin')
9 files changed, 362 insertions, 129 deletions
diff --git a/plugins/base/src/main/kotlin/DokkaBaseConfiguration.kt b/plugins/base/src/main/kotlin/DokkaBaseConfiguration.kt index 8ea8818d..a9ccf600 100644 --- a/plugins/base/src/main/kotlin/DokkaBaseConfiguration.kt +++ b/plugins/base/src/main/kotlin/DokkaBaseConfiguration.kt @@ -9,7 +9,8 @@ data class DokkaBaseConfiguration( var customAssets: List<File> = defaultCustomAssets, var separateInheritedMembers: Boolean = separateInheritedMembersDefault, var footerMessage: String = defaultFooterMessage, - var mergeImplicitExpectActualDeclarations: Boolean = mergeImplicitExpectActualDeclarationsDefault + var mergeImplicitExpectActualDeclarations: Boolean = mergeImplicitExpectActualDeclarationsDefault, + var templatesDir: File? = defaultTemplatesDir ) : ConfigurableBlock { companion object { val defaultFooterMessage = "© ${Year.now().value} Copyright" @@ -17,5 +18,6 @@ data class DokkaBaseConfiguration( val defaultCustomAssets: List<File> = emptyList() const val separateInheritedMembersDefault: Boolean = false const val mergeImplicitExpectActualDeclarationsDefault: Boolean = false + val defaultTemplatesDir: File? = null } }
\ No newline at end of file diff --git a/plugins/base/src/main/kotlin/renderers/contentTypeChecking.kt b/plugins/base/src/main/kotlin/renderers/contentTypeChecking.kt index 4619bc53..1cec4769 100644 --- a/plugins/base/src/main/kotlin/renderers/contentTypeChecking.kt +++ b/plugins/base/src/main/kotlin/renderers/contentTypeChecking.kt @@ -8,8 +8,11 @@ fun ContentEmbeddedResource.isImage(): Boolean { return File(address).extension.toLowerCase() in imageExtensions } +val String.URIExtension: String + get() = substringBefore('?').substringAfterLast('.') + fun String.isImage(): Boolean = - substringBefore('?').substringAfterLast('.') in imageExtensions + URIExtension in imageExtensions object HtmlFileExtensions { val imageExtensions = setOf("png", "jpg", "jpeg", "gif", "bmp", "tif", "webp", "svg") diff --git a/plugins/base/src/main/kotlin/renderers/html/HtmlRenderer.kt b/plugins/base/src/main/kotlin/renderers/html/HtmlRenderer.kt index 0ba085cd..05559469 100644 --- a/plugins/base/src/main/kotlin/renderers/html/HtmlRenderer.kt +++ b/plugins/base/src/main/kotlin/renderers/html/HtmlRenderer.kt @@ -4,10 +4,12 @@ import kotlinx.html.* import kotlinx.html.stream.createHTML import org.jetbrains.dokka.DokkaSourceSetID import org.jetbrains.dokka.base.DokkaBase -import org.jetbrains.dokka.base.DokkaBaseConfiguration -import org.jetbrains.dokka.base.DokkaBaseConfiguration.Companion.defaultFooterMessage import org.jetbrains.dokka.base.renderers.* import org.jetbrains.dokka.base.renderers.html.command.consumers.ImmediateResolutionTagConsumer +import org.jetbrains.dokka.base.renderers.html.innerTemplating.DefaultTemplateModelFactory +import org.jetbrains.dokka.base.renderers.html.innerTemplating.DefaultTemplateModelMerger +import org.jetbrains.dokka.base.renderers.html.innerTemplating.DokkaTemplateTypes +import org.jetbrains.dokka.base.renderers.html.innerTemplating.HtmlTemplater import org.jetbrains.dokka.base.resolvers.anchors.SymbolAnchorHint import org.jetbrains.dokka.base.resolvers.local.DokkaBaseLocationProvider import org.jetbrains.dokka.base.templating.* @@ -28,8 +30,6 @@ internal const val TEMPLATE_REPLACEMENT: String = "###" open class HtmlRenderer( context: DokkaContext ) : DefaultRenderer<FlowContent>(context) { - private val configuration = configuration<DokkaBase, DokkaBaseConfiguration>(context) - private val sourceSetDependencyMap: Map<DokkaSourceSetID, List<DokkaSourceSetID>> = context.configuration.sourceSets.associate { sourceSet -> sourceSet.sourceSetID to context.configuration.sourceSets @@ -37,6 +37,12 @@ open class HtmlRenderer( .filter { it in sourceSet.dependentSourceSets } } + private val templateModelFactories = listOf(DefaultTemplateModelFactory(context)) // TODO: Make extension point + private val templateModelMerger = DefaultTemplateModelMerger() + private val templater = HtmlTemplater(context).apply { + setupSharedModel(templateModelMerger.invoke(templateModelFactories) { buildSharedModel() }) + } + private var shouldRenderSourceSetBubbles: Boolean = false override val preprocessors = context.plugin<DokkaBase>().query { htmlPreprocessors } @@ -764,138 +770,34 @@ open class HtmlRenderer( override fun buildPage(page: ContentPage, content: (FlowContent, ContentPage) -> Unit): String = buildHtml(page, page.embeddedResources) { - div("main-content") { - id = "content" - attributes["pageIds"] = "${context.configuration.moduleName}::${page.pageId}" - content(this, page) - } + content(this, page) } private val String.isAbsolute: Boolean get() = URI(this).isAbsolute - open fun buildHtml(page: PageNode, resources: List<String>, content: FlowContent.() -> Unit): String { - val path = locationProvider.resolve(page) - val pathToRoot = locationProvider.pathToRoot(page) - return createHTML().prepareForTemplates().html { - head { - meta(name = "viewport", content = "width=device-width, initial-scale=1", charset = "UTF-8") - title(page.name) - templateCommandAsHtmlComment(PathToRootSubstitutionCommand(TEMPLATE_REPLACEMENT, default = pathToRoot)) { - link(href = "${TEMPLATE_REPLACEMENT}images/logo-icon.svg", rel = "icon", type = "image/svg") - } - templateCommandAsHtmlComment(PathToRootSubstitutionCommand(TEMPLATE_REPLACEMENT, default = pathToRoot)) { - script { unsafe { +"""var pathToRoot = "$TEMPLATE_REPLACEMENT";""" } } - } - // This script doesn't need to be there but it is nice to have since app in dark mode doesn't 'blink' (class is added before it is rendered) - script { - unsafe { - +""" - const storage = localStorage.getItem("dokka-dark-mode") - const savedDarkMode = storage ? JSON.parse(storage) : false - if(savedDarkMode === true){ - document.getElementsByTagName("html")[0].classList.add("theme-dark") - } - """.trimIndent() - } - } - resources.forEach { - when { - it.substringBefore('?').substringAfterLast('.') == "css" -> - if (it.isAbsolute) link( - rel = LinkRel.stylesheet, - href = it - ) - else templateCommandAsHtmlComment(PathToRootSubstitutionCommand(TEMPLATE_REPLACEMENT, default = pathToRoot)) { - link( - rel = LinkRel.stylesheet, - href = TEMPLATE_REPLACEMENT + it - ) - } - it.substringBefore('?').substringAfterLast('.') == "js" -> - if (it.isAbsolute) script( - type = ScriptType.textJavaScript, - src = it - ) { - async = true - } else templateCommandAsHtmlComment(PathToRootSubstitutionCommand(TEMPLATE_REPLACEMENT, default = pathToRoot)) { - script( - type = ScriptType.textJavaScript, - src = TEMPLATE_REPLACEMENT + it - ) { - if (it == "scripts/main.js") - defer = true - else - async = true - } - } - it.isImage() -> if (it.isAbsolute) link(href = it) - else templateCommandAsHtmlComment(PathToRootSubstitutionCommand(TEMPLATE_REPLACEMENT, default = pathToRoot)) { - link(href = TEMPLATE_REPLACEMENT + it) - } - else -> unsafe { +it } - } - } - } - body { - div("navigation-wrapper") { - id = "navigation-wrapper" - div { - id = "leftToggler" - span("icon-toggler") - } - div("library-name") { - clickableLogo(page, pathToRoot) - } - div { templateCommand(ReplaceVersionsCommand(path.orEmpty())) } - div("pull-right d-flex") { - filterButtons(page) - button { - id = "theme-toggle-button" - span { - id = "theme-toggle" - } - } - div { - id = "searchBar" - } - } - } - div { - id = "container" - div { - id = "leftColumn" - div { - id = "sideMenu" - } - } - div { - id = "main" - content() - div(classes = "footer") { - span("go-to-top-icon") { - a(href = "#content") { - id = "go-to-top-link" - } - } - span { - configuration?.footerMessage?.takeIf { it.isNotEmpty() } - ?.let { unsafe { raw(it) } } - ?: text(defaultFooterMessage) - } - span("pull-right") { - span { text("Generated by ") } - a(href = "https://github.com/Kotlin/dokka") { - span { text("dokka") } - span(classes = "padded-icon") - } - } - } + + open fun buildHtml(page: PageNode, resources: List<String>, content: FlowContent.() -> Unit): String = + templater.renderFromTemplate(DokkaTemplateTypes.BASE) { + val generatedContent = + createHTML().div("main-content") { + id = "content" + (page as? ContentPage)?.let { + attributes["pageIds"] = "${context.configuration.moduleName}::${page.pageId}" } + content() } + + templateModelMerger.invoke(templateModelFactories) { + buildModel( + page, + resources, + locationProvider, + shouldRenderSourceSetBubbles, + generatedContent + ) } } - } /** * This is deliberately left open for plugins that have some other pages above ours and would like to link to them diff --git a/plugins/base/src/main/kotlin/renderers/html/Tags.kt b/plugins/base/src/main/kotlin/renderers/html/Tags.kt index 94a53c27..ef27b934 100644 --- a/plugins/base/src/main/kotlin/renderers/html/Tags.kt +++ b/plugins/base/src/main/kotlin/renderers/html/Tags.kt @@ -38,6 +38,12 @@ fun FlowOrMetaDataContent.templateCommandAsHtmlComment(data: Command, block: Flo comment(TEMPLATE_COMMAND_END_BORDER) } +fun <T: Appendable> T.templateCommandAsHtmlComment(command: Command, action: T.() -> Unit ) { + append("<!--$TEMPLATE_COMMAND_BEGIN_BORDER$TEMPLATE_COMMAND_SEPARATOR${toJsonString(command)}-->") + action() + append("<!--$TEMPLATE_COMMAND_END_BORDER-->") +} + fun FlowOrMetaDataContent.templateCommand(data: Command, block: TemplateBlock = {}): Unit = (consumer as? ImmediateResolutionTagConsumer)?.processCommand(data, block) ?: TemplateCommand(attributesMapOf("data", toJsonString(data)), consumer).visit(block) diff --git a/plugins/base/src/main/kotlin/renderers/html/innerTemplating/DefaultTemplateModelFactory.kt b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/DefaultTemplateModelFactory.kt new file mode 100644 index 00000000..9f1ca57e --- /dev/null +++ b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/DefaultTemplateModelFactory.kt @@ -0,0 +1,207 @@ +package org.jetbrains.dokka.base.renderers.html.innerTemplating + +import freemarker.core.Environment +import freemarker.template.* +import kotlinx.html.* +import kotlinx.html.stream.createHTML +import org.jetbrains.dokka.DokkaConfiguration +import org.jetbrains.dokka.base.DokkaBase +import org.jetbrains.dokka.base.DokkaBaseConfiguration +import org.jetbrains.dokka.base.renderers.URIExtension +import org.jetbrains.dokka.base.renderers.html.TEMPLATE_REPLACEMENT +import org.jetbrains.dokka.base.renderers.html.command.consumers.ImmediateResolutionTagConsumer +import org.jetbrains.dokka.base.renderers.html.templateCommand +import org.jetbrains.dokka.base.renderers.html.templateCommandAsHtmlComment +import org.jetbrains.dokka.base.renderers.isImage +import org.jetbrains.dokka.base.resolvers.local.LocationProvider +import org.jetbrains.dokka.base.templating.PathToRootSubstitutionCommand +import org.jetbrains.dokka.base.templating.ProjectNameSubstitutionCommand +import org.jetbrains.dokka.base.templating.ReplaceVersionsCommand +import org.jetbrains.dokka.base.templating.SubstitutionCommand +import org.jetbrains.dokka.model.DisplaySourceSet +import org.jetbrains.dokka.model.withDescendants +import org.jetbrains.dokka.pages.ContentPage +import org.jetbrains.dokka.pages.PageNode +import org.jetbrains.dokka.plugability.DokkaContext +import org.jetbrains.dokka.plugability.configuration +import java.net.URI + +class DefaultTemplateModelFactory(val context: DokkaContext) : TemplateModelFactory { + private val configuration = configuration<DokkaBase, DokkaBaseConfiguration>(context) + private val isPartial = context.configuration.delayTemplateSubstitution + + private fun <R> TagConsumer<R>.prepareForTemplates() = + if (context.configuration.delayTemplateSubstitution || this is ImmediateResolutionTagConsumer) this + else ImmediateResolutionTagConsumer(this, context) + + data class SourceSetModel(val name: String, val platform: String, val filter: String) + + override fun buildModel( + page: PageNode, + resources: List<String>, + locationProvider: LocationProvider, + shouldRenderSourceSetBubbles: Boolean, + content: String + ): TemplateMap { + val path = locationProvider.resolve(page) + val pathToRoot = locationProvider.pathToRoot(page) + val mapper = mutableMapOf<String, Any>() + mapper["pageName"] = page.name + mapper["resources"] = PrintDirective { + val sb = StringBuilder() + if (isPartial) + sb.templateCommandAsHtmlComment( + PathToRootSubstitutionCommand( + TEMPLATE_REPLACEMENT, + default = pathToRoot + ) + ) { resourcesForPage(TEMPLATE_REPLACEMENT, resources) } + else + sb.resourcesForPage(pathToRoot, resources) + sb.toString() + } + mapper["content"] = PrintDirective { content } + mapper["version"] = PrintDirective { + createHTML().prepareForTemplates().templateCommand(ReplaceVersionsCommand(path.orEmpty())) + } + mapper["template_cmd"] = TemplateDirective(context.configuration, pathToRoot) + + if (shouldRenderSourceSetBubbles && page is ContentPage) { + val sourceSets = page.content.withDescendants() + .flatMap { it.sourceSets } + .distinct() + .sortedBy { it.comparableKey } + .map { SourceSetModel(it.name, it.platform.key, it.sourceSetIDs.merged.toString()) } + .toList() + mapper["sourceSets"] = sourceSets + } + return mapper + } + + override fun buildSharedModel(): TemplateMap = mapOf<String, Any>( + "footerMessage" to (configuration?.footerMessage?.takeIf { it.isNotEmpty() } + ?: DokkaBaseConfiguration.defaultFooterMessage) + ) + + private val DisplaySourceSet.comparableKey + get() = sourceSetIDs.merged.let { it.scopeId + it.sourceSetName } + private val String.isAbsolute: Boolean + get() = URI(this).isAbsolute + + private fun Appendable.resourcesForPage(pathToRoot: String, resources: List<String>): Unit = + resources.forEach { + append(with(createHTML()) { + when { + it.URIExtension == "css" -> + link( + rel = LinkRel.stylesheet, + href = if (it.isAbsolute) it else "$pathToRoot$it" + ) + it.URIExtension == "js" -> + script( + type = ScriptType.textJavaScript, + src = if (it.isAbsolute) it else "$pathToRoot$it" + ) { + if (it == "scripts/main.js") + defer = true + else + async = true + } + it.isImage() -> link(href = if (it.isAbsolute) it else "$pathToRoot$it") + else -> null + } + } ?: it) + } +} + +private class PrintDirective(val generateData: () -> String) : TemplateDirectiveModel { + override fun execute( + env: Environment, + params: MutableMap<Any?, Any?>?, + loopVars: Array<TemplateModel>?, + body: TemplateDirectiveBody? + ) { + if (params?.isNotEmpty() == true) throw TemplateModelException( + "Parameters are not allowed" + ) + if (loopVars?.isNotEmpty() == true) throw TemplateModelException( + "Loop variables are not allowed" + ) + env.out.write(generateData()) + } +} + +private class TemplateDirective(val configuration: DokkaConfiguration, val pathToRoot: String) : TemplateDirectiveModel { + override fun execute( + env: Environment, + params: MutableMap<Any?, Any?>?, + loopVars: Array<TemplateModel>?, + body: TemplateDirectiveBody? + ) { + val commandName = params?.get(PARAM_NAME) ?: throw TemplateModelException( + "The required $PARAM_NAME parameter is missing." + ) + val replacement = (params[PARAM_REPLACEMENT] as? SimpleScalar)?.asString ?: TEMPLATE_REPLACEMENT + + when ((commandName as? SimpleScalar)?.asString) { + "pathToRoot" -> { + body ?: throw TemplateModelException( + "No directive body for $commandName command." + ) + executeSubstituteCommand( + PathToRootSubstitutionCommand( + replacement, pathToRoot + ), + "pathToRoot", + pathToRoot, + Context(env, body) + ) + } + "projectName" -> { + body ?: throw TemplateModelException( + "No directive body $commandName command." + ) + executeSubstituteCommand( + ProjectNameSubstitutionCommand( + replacement, configuration.moduleName + ), + "projectName", + configuration.moduleName, + Context(env, body) + ) + } + else -> throw TemplateModelException( + "The parameter $PARAM_NAME $commandName is unknown" + ) + } + } + + private data class Context(val env: Environment, val body: TemplateDirectiveBody) + + private fun executeSubstituteCommand( + command: SubstitutionCommand, + name: String, + value: String, + ctx: Context + ) { + if (configuration.delayTemplateSubstitution) + ctx.env.out.templateCommandAsHtmlComment(command) { + renderWithLocalVar(name, command.pattern, ctx) + } + else { + renderWithLocalVar(name, value, ctx) + } + } + + private fun renderWithLocalVar(name: String, value: String, ctx: Context) = + with(ctx) { + env.setVariable(name, SimpleScalar(value)) + body.render(env.out) + env.setVariable(name, null) + } + + companion object { + const val PARAM_NAME = "name" + const val PARAM_REPLACEMENT = "replacement" + } +}
\ No newline at end of file diff --git a/plugins/base/src/main/kotlin/renderers/html/innerTemplating/DefaultTemplateModelMerger.kt b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/DefaultTemplateModelMerger.kt new file mode 100644 index 00000000..7d548721 --- /dev/null +++ b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/DefaultTemplateModelMerger.kt @@ -0,0 +1,16 @@ +package org.jetbrains.dokka.base.renderers.html.innerTemplating + +class DefaultTemplateModelMerger : TemplateModelMerger { + override fun invoke( + factories: List<TemplateModelFactory>, + buildModel: TemplateModelFactory.() -> TemplateMap + ): TemplateMap { + val mapper = mutableMapOf<String, Any?>() + factories.map(buildModel).forEach { partialModel -> + partialModel.forEach { (k, v) -> + mapper[k] = v + } + } + return mapper + } +}
\ No newline at end of file diff --git a/plugins/base/src/main/kotlin/renderers/html/innerTemplating/HtmlTemplater.kt b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/HtmlTemplater.kt new file mode 100644 index 00000000..e3d16d98 --- /dev/null +++ b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/HtmlTemplater.kt @@ -0,0 +1,76 @@ +package org.jetbrains.dokka.base.renderers.html.innerTemplating + +import freemarker.cache.ClassTemplateLoader +import freemarker.cache.FileTemplateLoader +import freemarker.cache.MultiTemplateLoader +import freemarker.log.Logger +import freemarker.template.Configuration +import freemarker.template.TemplateExceptionHandler +import org.jetbrains.dokka.base.DokkaBase +import org.jetbrains.dokka.base.DokkaBaseConfiguration +import org.jetbrains.dokka.plugability.DokkaContext +import org.jetbrains.dokka.plugability.configuration +import java.io.StringWriter + + +enum class DokkaTemplateTypes(val path: String) { + BASE("base.ftl") +} + +typealias TemplateMap = Map<String, Any?> + +class HtmlTemplater( + context: DokkaContext +) { + + init { + // to disable logging, but it isn't reliable see [Logger.SYSTEM_PROPERTY_NAME_LOGGER_LIBRARY] + // (use SLF4j further) + System.setProperty( + Logger.SYSTEM_PROPERTY_NAME_LOGGER_LIBRARY, + System.getProperty(Logger.SYSTEM_PROPERTY_NAME_LOGGER_LIBRARY) ?: Logger.LIBRARY_NAME_NONE + ) + } + + private val configuration = configuration<DokkaBase, DokkaBaseConfiguration>(context) + private val templaterConfiguration = + Configuration(Configuration.VERSION_2_3_31).apply { configureTemplateEngine() } + + private fun Configuration.configureTemplateEngine() { + val loaderFromResources = ClassTemplateLoader(javaClass, "/dokka/templates") + templateLoader = configuration?.templatesDir?.let { + MultiTemplateLoader( + arrayOf( + FileTemplateLoader(it), + loaderFromResources + ) + ) + } ?: loaderFromResources + + unsetLocale() + defaultEncoding = "UTF-8" + templateExceptionHandler = TemplateExceptionHandler.RETHROW_HANDLER + logTemplateExceptions = false + wrapUncheckedExceptions = true + fallbackOnNullLoopVariable = false + templateUpdateDelayMilliseconds = Long.MAX_VALUE + } + + fun setupSharedModel(model: TemplateMap) { + templaterConfiguration.setSharedVariables(model) + } + + fun renderFromTemplate( + templateType: DokkaTemplateTypes, + generateModel: () -> TemplateMap + ): String { + val out = StringWriter() + // Freemarker has own thread-safe cache to keep templates + val template = templaterConfiguration.getTemplate(templateType.path) + val model = generateModel() + template.process(model, out) + + return out.toString() + } +} + diff --git a/plugins/base/src/main/kotlin/renderers/html/innerTemplating/TemplateModelFactory.kt b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/TemplateModelFactory.kt new file mode 100644 index 00000000..ceecf201 --- /dev/null +++ b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/TemplateModelFactory.kt @@ -0,0 +1,16 @@ +package org.jetbrains.dokka.base.renderers.html.innerTemplating + +import org.jetbrains.dokka.base.resolvers.local.LocationProvider +import org.jetbrains.dokka.pages.PageNode + +interface TemplateModelFactory { + fun buildModel( + page: PageNode, + resources: List<String>, + locationProvider: LocationProvider, + shouldRenderSourceSetBubbles: Boolean, + content: String + ): TemplateMap + + fun buildSharedModel(): TemplateMap +}
\ No newline at end of file diff --git a/plugins/base/src/main/kotlin/renderers/html/innerTemplating/TemplateModelMerger.kt b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/TemplateModelMerger.kt new file mode 100644 index 00000000..7ad96d8f --- /dev/null +++ b/plugins/base/src/main/kotlin/renderers/html/innerTemplating/TemplateModelMerger.kt @@ -0,0 +1,5 @@ +package org.jetbrains.dokka.base.renderers.html.innerTemplating + +fun interface TemplateModelMerger { + fun invoke(factories: List<TemplateModelFactory>, buildModel: TemplateModelFactory.() -> TemplateMap): TemplateMap +}
\ No newline at end of file |