Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
17ec004
Add `SitemapBlock` for SEO-related configurations in `AppBlock`
kocheick Aug 21, 2025
97c767b
Add `sitemap` configuration with base URL and excluded routes
kocheick Aug 21, 2025
fbd7ea6
Integrate `kobwebGenerateSitemapTask` to generate XML sitemaps for SEO
kocheick Aug 21, 2025
8fc0b7c
fixed excluded routes still being generated
kocheick Aug 21, 2025
a3309f9
Fix trailing slash issue in excluded sitemap route for `/example/`
kocheick Aug 21, 2025
b0661b1
Refactor `KobwebGenerateSitemapTask` to streamline route filtering an…
kocheick Sep 4, 2025
7afe63d
Refactor `KobwebGenerateSitemapTask` setup to clean up task registrat…
kocheick Sep 4, 2025
659f872
Refactor `SitemapBlock` to replace `includeDynamicRoutes` and `exclud…
kocheick Sep 4, 2025
f09b401
Refactor sitemap generation to use `generateSitemap`
kocheick Sep 4, 2025
4b648b6
Conditionally register `kobwebGenerateSitemapTask` based on sitemap c…
kocheick Sep 4, 2025
5a743d6
Track sitemap generation state using an internal flag and enable it d…
kocheick Sep 4, 2025
cd9e9a7
removed unnecessary dynamic route handling, and comments
kocheick Sep 11, 2025
b65c922
refactor sitemap generation and copy tasks for better integration and…
kocheick Sep 11, 2025
b523f90
Refactor `SitemapBlock` annotations to use `@Nested` for improved ser…
kocheick Sep 11, 2025
d839077
Refactor `kobwebSiteRoutes` property to use `flatMap` for improved fi…
kocheick Sep 11, 2025
52fc315
refactor sitemap generation task to ensure output directory exists be…
kocheick Sep 11, 2025
7ff2bf5
update sitemap baseurl handling to provide a warning instead of an er…
kocheick Sep 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions playground/site/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ kobweb {
enableSelfHosting()
}
}
//testing new feature
generateSitemap("http://localhost:8080") {
// Define routes to exclude from sitemap
val excludedPrefixes = listOf("/fruits", "/markdown")
filter.set {
// Exclude routes with specified prefixes
val hasExcludedPrefix = excludedPrefixes.any { prefix -> route.startsWith(prefix) }
!hasExcludedPrefix
}
}
}
markdown {
defaultLayout.set(".components.layouts.MarkdownLayout")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.varabyte.kobweb.gradle.application.extensions.createAppBlock
import com.varabyte.kobweb.gradle.application.extensions.export
import com.varabyte.kobweb.gradle.application.extensions.remoteDebugging
import com.varabyte.kobweb.gradle.application.extensions.server
import com.varabyte.kobweb.gradle.application.extensions.sitemap
import com.varabyte.kobweb.gradle.application.ksp.kspBackendFile
import com.varabyte.kobweb.gradle.application.ksp.kspFrontendFile
import com.varabyte.kobweb.gradle.application.tasks.KobwebBrowserCacheIdTask
Expand All @@ -21,13 +22,15 @@ import com.varabyte.kobweb.gradle.application.tasks.KobwebExportTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebGenIndexConfInputs
import com.varabyte.kobweb.gradle.application.tasks.KobwebGenSiteEntryConfInputs
import com.varabyte.kobweb.gradle.application.tasks.KobwebGenerateApisFactoryTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebGenerateSitemapTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebGenerateSiteEntryTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebGenerateSiteIndexTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebGenerateTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebListRoutesTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebStartTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebStopTask
import com.varabyte.kobweb.gradle.application.tasks.KobwebUnpackServerJarTask
import com.varabyte.kobweb.gradle.application.util.site.kobwebSiteRoutes
import com.varabyte.kobweb.gradle.core.KobwebCorePlugin
import com.varabyte.kobweb.gradle.core.extensions.kobwebBlock
import com.varabyte.kobweb.gradle.core.kmp.JsTarget
Expand Down Expand Up @@ -71,6 +74,9 @@ import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack
import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget
import javax.inject.Inject
import kotlin.io.path.exists
import org.gradle.api.Task
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Delete

val Project.kobwebFolder: KobwebFolder
get() = KobwebFolder.fromChildPath(layout.projectDirectory.asFile.toPath())
Expand Down Expand Up @@ -173,6 +179,7 @@ class KobwebApplicationPlugin @Inject constructor(
"\"${devScript.substringAfterLast('/')}\" to find the right path."
)
}

}
}
project.tasks.register<KobwebStopTask>("kobwebStop")
Expand Down Expand Up @@ -211,7 +218,6 @@ class KobwebApplicationPlugin @Inject constructor(
KobwebExportConfInputs(kobwebConf),
exportLayout,
)

val kobwebListRoutesTask = project.tasks.register<KobwebListRoutesTask>("kobwebListRoutes")

project.tasks.register<KobwebBrowserCacheIdTask>("kobwebBrowserCacheId") {
Expand All @@ -237,6 +243,7 @@ class KobwebApplicationPlugin @Inject constructor(
project.buildTargets.withType<KotlinJsIrTarget>().configureEach {
val jsTarget = JsTarget(this)


// Beginning with Kotlin 2.1.0, the Kotlin Gradle plugin began using uncompressed klibs for
// inter-project dependencies. This breaks our code responsible for detecting and extracting metadata from
// kobweb library dependencies, as the required resources (e.g. `KOBWEB_METADATA_FRONTEND`) are not present
Expand Down Expand Up @@ -282,11 +289,12 @@ class KobwebApplicationPlugin @Inject constructor(
KobwebGenSiteEntryConfInputs(kobwebConf),
)

val kobwebCacheAppFrontendDataTask = project.tasks.register<KobwebCacheAppFrontendDataTask>("kobwebCacheAppFrontendData") {
appFrontendMetadataFile.set(project.kspFrontendFile(jsTarget))
compileClasspath.from(project.configurations.named(jsTarget.compileClasspath))
appDataFile.set(this.kobwebCacheFile("appData.json"))
}
val kobwebCacheAppFrontendDataTask =
project.tasks.register<KobwebCacheAppFrontendDataTask>("kobwebCacheAppFrontendData") {
appFrontendMetadataFile.set(project.kspFrontendFile(jsTarget))
compileClasspath.from(project.configurations.named(jsTarget.compileClasspath))
appDataFile.set(this.kobwebCacheFile("appData.json"))
}

kobwebGenSiteEntryTask.configure {
appDataFile.set(kobwebCacheAppFrontendDataTask.flatMap { it.appDataFile })
Expand Down Expand Up @@ -365,7 +373,106 @@ class KobwebApplicationPlugin @Inject constructor(
kobwebListRoutesTask.configure {
appDataFile.set(kobwebCacheAppFrontendDataTask.flatMap { it.appDataFile })
}

// Only register sitemap generation task if sitemap generation has been enabled
if (appBlock.sitemap.isEnabled) {
val kobwebGenerateSitemapTask = project.tasks.register<KobwebGenerateSitemapTask>(
"kobwebGenerateSitemap"
) {
routes.set(project.kobwebSiteRoutes)
basePath.set(kobwebConf.site.basePath)

// Generate to a temporary location first
sitemapFile.set(
project.layout.buildDirectory.file("tmp/sitemap/sitemap.xml")
)

sitemapBlock.set(appBlock.sitemap)

// Ensure sitemap generation waits for app data to be cached
dependsOn(kobwebCacheAppFrontendDataTask)

// Capture the baseUrl presence during configuration time
val hasBaseUrl = appBlock.sitemap.baseUrl.isPresent
onlyIf("baseUrl must be configured for sitemap generation") {
if (!hasBaseUrl) {
logger.info("Skipping sitemap generation: baseUrl not configured")
}
hasBaseUrl
}
}

// Copy the generated sitemap to the source resources directory
val copySitemapToResourcesTask = project.tasks.register<Copy>("copySitemapToResources") {
group = "kobweb"
description = "Copy generated sitemap to source resources directory"

from(kobwebGenerateSitemapTask.flatMap { it.sitemapFile })

val targetDirProvider = project.provider {
project.file("src/${jsTarget.mainSourceSet}/resources/${kobwebBlock.publicPath.get()}")
}
into(targetDirProvider)

dependsOn(kobwebGenerateSitemapTask)

// Capture the baseUrl presence during configuration time
val hasBaseUrl = appBlock.sitemap.baseUrl.isPresent
onlyIf {
if (!hasBaseUrl) {
logger.info("Skipping sitemap copy: baseUrl not configured")
}
hasBaseUrl
}

doFirst {
val targetDir = targetDirProvider.get()
targetDir.mkdirs()
logger.info("Copying sitemap to: ${targetDir.absolutePath}")
}

doLast {
logger.info("Successfully copied sitemap.xml to source resources")
}
}

// Make sure sitemap is copied before resources are processed
project.tasks.named<ProcessResources>(jsTarget.processResources) {
dependsOn(copySitemapToResourcesTask)
}

// Also ensure sitemap is available during development
kobwebStartTask.configure {
dependsOn(copySitemapToResourcesTask)
}

// Make the copy task part of the general generation tasks
project.tasks.matching { it.name == "kobwebGenAll" }.configureEach {
dependsOn(copySitemapToResourcesTask)
}

// For production builds, ensure sitemap is copied before webpack
project.tasks.matching { it.name == jsTarget.browserProductionWebpack }.configureEach {
dependsOn(copySitemapToResourcesTask)
}

// Add a cleanup task for sitemap files
project.tasks.register<Delete>("cleanSitemap") {
group = "kobweb"
description = "Clean generated sitemap files"

delete(project.layout.buildDirectory.file("tmp/sitemap"))
delete(project.provider {
project.file("src/${jsTarget.mainSourceSet}/resources/${kobwebBlock.publicPath.get()}/sitemap.xml")
})

doLast {
logger.info("Cleaned sitemap files")
}
}
}
}

project.buildTargets.withType<KotlinJvmTarget>().configureEach {
val jvmTarget = JvmTarget(this)

Expand All @@ -380,11 +487,12 @@ class KobwebApplicationPlugin @Inject constructor(
}
}

val kobwebCacheAppBackendDataTask = project.tasks.register<KobwebCacheAppBackendDataTask>("kobwebCacheAppBackendData") {
appBackendMetadataFile.set(project.kspBackendFile(jvmTarget))
compileClasspath.from(project.configurations.named(jvmTarget.compileClasspath))
appDataFile.set(this.kobwebCacheFile("appData.json"))
}
val kobwebCacheAppBackendDataTask =
project.tasks.register<KobwebCacheAppBackendDataTask>("kobwebCacheAppBackendData") {
appBackendMetadataFile.set(project.kspBackendFile(jvmTarget))
compileClasspath.from(project.configurations.named(jvmTarget.compileClasspath))
appDataFile.set(this.kobwebCacheFile("appData.json"))
}

val kobwebGenApisFactoryTask = project.tasks
.register<KobwebGenerateApisFactoryTask>("kobwebGenApisFactory", kobwebBlock.app)
Expand Down Expand Up @@ -412,7 +520,6 @@ class KobwebApplicationPlugin @Inject constructor(
kotlin.srcDir(kobwebGenApisFactoryTask)
}
}

// Convenience task in case you quickly want to run all "kobwebGen..." tasks
project.tasks.register("kobwebGenAll") {
group = "kobweb"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.varabyte.kobweb.project.conf.KobwebConf
import kotlinx.html.HEAD
import kotlinx.html.link
import kotlinx.html.meta
import org.gradle.api.GradleException
import org.gradle.api.plugins.ExtensionAware
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
Expand Down Expand Up @@ -328,6 +329,78 @@ abstract class AppBlock @Inject constructor(
}
}


/**
* A sub-block for defining properties related to sitemap generation for SEO.
*/
abstract class SitemapBlock : ExtensionAware {


/**
* Context for sitemap route filtering, providing extensible information about each route.
*/
class SitemapFilterContext(val route: String)

/**
* The base URL for the site (e.g., "https://example.com").
* This will be prepended to all routes in the sitemap.
*
* IMPORTANT: If your site is deployed under a base path (e.g., GitHub Pages project site),
* include it in the baseUrl: "https://username.github.io/project-name".
* If the base path is configured and not included in the baseUrl, a warning will be printed and the
* generated links may be incorrect.
*/
@get:Input
abstract val baseUrl: Property<String>

/**
* Additional routes to include that may not be discovered automatically.
* Useful for dynamic content routes where you know the specific URLs.
*
* For markdown-generated pages: these are already discovered automatically.
* For dynamic templates like @Page("/blog/{slug}"): add concrete URLs like "/blog/my-post".
*/
@get:Input
abstract val extraRoutes: ListProperty<String>

/**
* A filter to determine which routes should be included in the sitemap.
* Return true to include the route, false to exclude it.
*
* Note: This property is marked as `@Internal` to avoid serialization issues with lambdas,
* which can affect Gradle's configuration cache.
*
* IMPORTANT: Dynamic routes (containing '{' and '}') are ALWAYS excluded automatically,
* even if you provide a custom filter. This is enforced by the framework.
*
* @see [SitemapFilterContext]
*/
@get:Nested // Avoid serialization issues with lambdas
abstract val filter: Property<SitemapFilterContext.() -> Boolean>

/**
* Internal flag to track whether sitemap generation has been enabled.
* This is used to conditionally register the sitemap generation task.
*/
@get:Internal
internal var isEnabled: Boolean = false
private set

/**
* Internal method to mark sitemap generation as enabled.
* Called by the generateSitemap function.
*/
internal fun markAsEnabled() {
isEnabled = true
}

init {
extraRoutes.set(emptyList())
// Default filter accepts all routes (dynamic route exclusion is handled in the task)
filter.convention { true }
}
}

/**
* Configuration values for the backend of this Kobweb application.
*/
Expand Down Expand Up @@ -574,13 +647,26 @@ abstract class AppBlock @Inject constructor(
@get:Input
abstract val cssPrefix: Property<String>

/**
* Enable sitemap generation for this Kobweb application.
*
* @param baseUrl The base URL for the site (e.g., "https://example.com")
* @param config Configuration block for additional sitemap options
*/
fun generateSitemap(baseUrl: String, config: SitemapBlock.() -> Unit = {}) {
sitemap.baseUrl.set(baseUrl)
config(sitemap)
sitemap.markAsEnabled()
}

init {
globals.set(mapOf("title" to conf.site.title))
cleanUrls.convention(true)
genDir.convention(baseGenDir.map { "$it/app" })

extensions.create<IndexBlock>("index", BasePath(conf.site.basePath))
extensions.create<ServerBlock>("server")
extensions.create<SitemapBlock>("sitemap")
extensions.create<ExportBlock>("export", kobwebFolder)
}
}
Expand All @@ -597,6 +683,9 @@ val AppBlock.export: AppBlock.ExportBlock
val AppBlock.server: AppBlock.ServerBlock
get() = extensions.getByType<AppBlock.ServerBlock>()

val AppBlock.sitemap: AppBlock.SitemapBlock
get() = extensions.getByType<AppBlock.SitemapBlock>()

val AppBlock.ServerBlock.remoteDebugging: AppBlock.ServerBlock.RemoteDebuggingBlock
get() = extensions.getByType<AppBlock.ServerBlock.RemoteDebuggingBlock>()

Expand Down
Loading