pkl-doc: Support single-package docsite mode (#1592)

When a docsite has only one package name and no DocsiteInfo.overview,
treat it like Javadoc's single-module output: redirect the top-level
index to the package page and omit the site-title breadcrumb segment
from generated pages.

Add src/test/files/SinglePackageTest fixtures to cover multiple package
versions, redirect behavior, breadcrumb behavior, and unchanged site
structure.

Also:
- Shut down Executor used in test.
- Declare expected output fixtures of DocGenerator as test inputs, not
outputs.
- Fix IntelliJ warning by using a Set for the right-hand side of
collection subtraction.
This commit is contained in:
odenix
2026-05-16 03:38:24 +02:00
committed by GitHub
parent a7a64acbac
commit 566c42f44d
65 changed files with 3611 additions and 104 deletions
@@ -26,8 +26,17 @@ internal class ClassPageGenerator(
clazz: PClass,
pageScope: ClassScope,
isTestMode: Boolean,
isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) : ModuleOrClassPageGenerator<ClassScope>(docsiteInfo, clazz, pageScope, isTestMode, consoleOut) {
) :
ModuleOrClassPageGenerator<ClassScope>(
docsiteInfo,
clazz,
pageScope,
isTestMode,
isSinglePackageSite,
consoleOut,
) {
override val html: HTML.() -> Unit = {
renderHtmlHead()
@@ -190,28 +190,11 @@ class DocGenerator(
"Docsite is not up to date. Expected: ${DocMigrator.CURRENT_VERSION}. Found: ${docMigrator.docsiteVersion}. Use DocMigrator to migrate the site."
)
}
val htmlGenerator =
HtmlGenerator(docsiteInfo, docPackages, importResolver, outputDir, isTestMode, consoleOut)
val searchIndexGenerator = SearchIndexGenerator(outputDir, consoleOut)
val packageDataGenerator = PackageDataGenerator(outputDir, consoleOut)
val runtimeDataGenerator =
RuntimeDataGenerator(descendingVersionComparator, outputDir, consoleOut)
coroutineScope {
for (docPackage in docPackages) {
launch {
docPackage.deletePackageDir()
coroutineScope {
launch { htmlGenerator.generate(docPackage) }
launch { searchIndexGenerator.generate(docPackage) }
launch { packageDataGenerator.generate(docPackage) }
}
}
}
}
writeOutputLine("Generated HTML for packages")
val newlyGeneratedPackages = docPackages.map(::PackageData).sortedBy { it.ref.pkg }
val currentSearchIndex = searchIndexGenerator.getCurrentSearchIndex()
@@ -226,6 +209,32 @@ class DocGenerator(
newlyGeneratedPackages + existingCurrentPackages,
descendingVersionComparator,
)
val isSinglePackageSite = docsiteInfo.overview == null && currentPackages.size == 1
val htmlGenerator =
HtmlGenerator(
docsiteInfo,
docPackages,
importResolver,
outputDir,
isTestMode,
isSinglePackageSite,
consoleOut,
)
coroutineScope {
for (docPackage in docPackages) {
launch {
docPackage.deletePackageDir()
coroutineScope {
launch { htmlGenerator.generate(docPackage) }
launch { searchIndexGenerator.generate(docPackage) }
launch { packageDataGenerator.generate(docPackage) }
}
}
}
}
writeOutputLine("Generated HTML for packages")
createCurrentDirectories(currentPackages, existingCurrentPackages)
searchIndexGenerator.generateSiteIndex(currentPackages)
@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -29,6 +29,10 @@ data class DocsiteInfo(
*
* Uses the same Markdown format as Pkldoc comments. Unless expanded, only the first paragraph is
* shown.
*
* If [overview] is `null` and the generated site has only one distinct package name, the main
* page redirects to that package page and generated breadcrumbs omit the site title segment. The
* structure of the generated site is unchanged.
*/
val overview: String?,
@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@ internal class HtmlGenerator(
importResolver: (URI) -> ModuleSchema,
private val outputDir: Path,
private val isTestMode: Boolean,
private val isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) : AbstractGenerator(consoleOut) {
private val siteScope =
@@ -35,14 +36,25 @@ internal class HtmlGenerator(
suspend fun generate(docPackage: DocPackage) = coroutineScope {
val packageScope = siteScope.getPackage(docPackage.docPackageInfo)
launch { PackagePageGenerator(docsiteInfo, docPackage, packageScope, consoleOut).run() }
launch {
PackagePageGenerator(docsiteInfo, docPackage, packageScope, isSinglePackageSite, consoleOut)
.run()
}
for (docModule in docPackage.docModules) {
if (docModule.isUnlisted) continue
val moduleScope = packageScope.getModule(docModule.name)
launch {
ModulePageGenerator(docsiteInfo, docPackage, docModule, moduleScope, isTestMode, consoleOut)
ModulePageGenerator(
docsiteInfo,
docPackage,
docModule,
moduleScope,
isTestMode,
isSinglePackageSite,
consoleOut,
)
.run()
}
@@ -56,6 +68,7 @@ internal class HtmlGenerator(
clazz,
ClassScope(clazz, moduleScope.url, moduleScope),
isTestMode,
isSinglePackageSite,
consoleOut,
)
.run()
@@ -65,7 +78,9 @@ internal class HtmlGenerator(
}
suspend fun generateSite(packages: List<PackageData>) = coroutineScope {
launch { MainPageGenerator(docsiteInfo, packages, siteScope, consoleOut).run() }
launch {
MainPageGenerator(docsiteInfo, packages, siteScope, isSinglePackageSite, consoleOut).run()
}
launch { generateStaticResources() }
}
@@ -21,8 +21,9 @@ import kotlinx.html.*
internal abstract class MainOrPackagePageGenerator<S>(
docsiteInfo: DocsiteInfo,
pageScope: S,
isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) : PageGenerator<S>(docsiteInfo, pageScope, consoleOut) where S : PageScope {
) : PageGenerator<S>(docsiteInfo, pageScope, isSinglePackageSite, consoleOut) where S : PageScope {
protected fun UL.renderModuleOrPackage(
name: String,
moduleOrPackageScope: DocScope,
@@ -17,47 +17,53 @@ package org.pkl.doc
import java.io.OutputStream
import kotlinx.html.*
import kotlinx.serialization.json.Json
internal class MainPageGenerator(
docsiteInfo: DocsiteInfo,
private val packagesData: List<PackageData>,
pageScope: SiteScope,
private val isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) : MainOrPackagePageGenerator<SiteScope>(docsiteInfo, pageScope, consoleOut) {
) : MainOrPackagePageGenerator<SiteScope>(docsiteInfo, pageScope, false, consoleOut) {
override val html: HTML.() -> Unit = {
renderHtmlHead()
if (isSinglePackageSite) {
renderRedirectPage()
} else {
renderHtmlHead()
body {
onLoad = "onLoad()"
body {
onLoad = "onLoad()"
renderPageHeader(null, null, null, null)
renderPageHeader(null, null, null, null)
main {
h1 {
id = "declaration-title"
main {
h1 {
id = "declaration-title"
+(docsiteInfo.title ?: "")
}
val memberDocs = MemberDocs(docsiteInfo.overview, pageScope, listOf(), isDeclaration = true)
renderMemberGroupLinks(
Triple("Overview", "#_overview", memberDocs.isExpandable),
Triple("Packages", "#_packages", packagesData.isNotEmpty()),
)
if (docsiteInfo.overview != null) {
renderAnchor("_overview")
div {
id = "_declaration"
classes = setOf("member")
memberDocs.renderExpandIcon(this)
memberDocs.renderDocComment(this)
+(docsiteInfo.title ?: "")
}
}
renderPackages()
val memberDocs =
MemberDocs(docsiteInfo.overview, pageScope, listOf(), isDeclaration = true)
renderMemberGroupLinks(
Triple("Overview", "#_overview", memberDocs.isExpandable),
Triple("Packages", "#_packages", packagesData.isNotEmpty()),
)
if (docsiteInfo.overview != null) {
renderAnchor("_overview")
div {
id = "_declaration"
classes = setOf("member")
memberDocs.renderExpandIcon(this)
memberDocs.renderDocComment(this)
}
}
renderPackages()
}
}
}
}
@@ -66,6 +72,28 @@ internal class MainPageGenerator(
+(docsiteInfo.title ?: "Pkldoc")
}
private fun HTML.renderRedirectPage() {
val packagePageUrl = "${packagesData.single().ref.basePath}/current/index.html"
lang = "en-US"
head {
meta { charset = "UTF-8" }
title { renderPageTitle() }
script { unsafe { raw("window.location.replace(${Json.encodeToString(packagePageUrl)});") } }
}
body {
main {
p {
a {
href = packagePageUrl
+packagePageUrl
}
}
}
}
}
private fun HtmlBlockTag.renderPackages() {
if (packagesData.isEmpty()) return
@@ -29,8 +29,9 @@ internal abstract class ModuleOrClassPageGenerator<S>(
protected val clazz: PClass,
scope: S,
private val isTestMode: Boolean,
isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) : PageGenerator<S>(docsiteInfo, scope, consoleOut) where S : PageScope {
) : PageGenerator<S>(docsiteInfo, scope, isSinglePackageSite, consoleOut) where S : PageScope {
protected fun HtmlBlockTag.renderProperties() {
if (!clazz.hasListedProperty) return
@@ -24,6 +24,7 @@ internal class ModulePageGenerator(
docModule: DocModule,
pageScope: ModuleScope,
isTestMode: Boolean,
isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) :
ModuleOrClassPageGenerator<ModuleScope>(
@@ -31,6 +32,7 @@ internal class ModulePageGenerator(
docModule.schema.moduleClass,
pageScope,
isTestMode,
isSinglePackageSite,
consoleOut,
) {
private val module = docModule.schema
@@ -22,8 +22,15 @@ internal class PackagePageGenerator(
docsiteInfo: DocsiteInfo,
private val docPackage: DocPackage,
pageScope: PackageScope,
isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) : MainOrPackagePageGenerator<PackageScope>(docsiteInfo, pageScope, consoleOut) {
) :
MainOrPackagePageGenerator<PackageScope>(
docsiteInfo,
pageScope,
isSinglePackageSite,
consoleOut,
) {
override val html: HTML.() -> Unit = {
renderHtmlHead()
@@ -32,6 +32,7 @@ import org.pkl.core.util.IoUtils
internal abstract class PageGenerator<out S>(
protected val docsiteInfo: DocsiteInfo,
protected val pageScope: S,
private val isSinglePackageSite: Boolean,
consoleOut: OutputStream,
) : AbstractGenerator(consoleOut) where S : PageScope {
companion object {
@@ -203,13 +204,6 @@ internal abstract class PageGenerator<out S>(
}
protected fun HtmlBlockTag.renderParentLinks() {
a {
classes = setOf("declaration-parent-link")
href = pageScope.relativeSiteUrl.toString()
+(docsiteInfo.title ?: "Pkldoc")
}
val packageScope =
when (pageScope) {
is ClassScope -> pageScope.parent!!.parent
@@ -217,33 +211,33 @@ internal abstract class PageGenerator<out S>(
else -> null
}
if (packageScope != null) {
+" > "
a {
classes = setOf("declaration-parent-link")
href = packageScope.urlRelativeTo(pageScope).toString()
+packageScope.name
}
}
val moduleScope =
when (pageScope) {
is ClassScope -> pageScope.parent
else -> null
}
if (moduleScope != null) {
+" > "
var isFirst = true
fun renderLink(text: String, url: String) {
if (isFirst) isFirst = false else +" > "
a {
classes = setOf("declaration-parent-link")
href = moduleScope.urlRelativeTo(pageScope).toString()
href = url
+moduleScope.name
+text
}
}
if (!isSinglePackageSite) {
renderLink(docsiteInfo.title ?: "Pkldoc", pageScope.relativeSiteUrl.toString())
}
if (packageScope != null) {
renderLink(packageScope.name, packageScope.urlRelativeTo(pageScope).toString())
}
if (moduleScope != null) {
renderLink(moduleScope.name, moduleScope.urlRelativeTo(pageScope).toString())
}
}
protected fun HtmlBlockTag.renderClassExtendsClause(clazz: PClass, currScope: DocScope) {