You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/02/20 01:34:36 UTC

(grails-core) branch refactor/centralize-groovydoc-plugin created (now 3c50821ea7)

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a change to branch refactor/centralize-groovydoc-plugin
in repository https://gitbox.apache.org/repos/asf/grails-core.git


      at 3c50821ea7 Centralize groovydoc configuration into GrailsGroovydocPlugin convention plugin

This branch includes the following new commits:

     new 3c50821ea7 Centralize groovydoc configuration into GrailsGroovydocPlugin convention plugin

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(grails-core) 01/01: Centralize groovydoc configuration into GrailsGroovydocPlugin convention plugin

Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch refactor/centralize-groovydoc-plugin
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 3c50821ea7aa98b15561e8e6672a45c91411e8f9
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Feb 19 20:32:21 2026 -0500

    Centralize groovydoc configuration into GrailsGroovydocPlugin convention plugin
    
    Move duplicated AntBuilder groovydoc execution, Matomo footer, documentation
    configuration registration, and task defaults into a shared convention plugin
    in build-logic. This eliminates ~490 lines of duplicated configuration across
    8 build scripts while maintaining identical behavior.
    
    The plugin provides:
    - Documentation configuration registration with standard attributes
    - Common Groovydoc task defaults (author, timestamps, scripts)
    - AntBuilder-based execution with javaVersion support (Groovy 4.0.27+)
    - Matomo analytics footer
    - Source directory resolution from ext.groovydocSourceDirs or source sets
    - External documentation link support via ext.groovydocLinks
    - GrailsGroovydocExtension for per-project javaVersion control
    
    Build scripts retain project-specific configuration: dependencies, titles,
    source directories for aggregate tasks, and dynamic link resolution.
    
    Assisted-by: Claude Code <Cl...@Claude.ai>
---
 build-logic/plugins/build.gradle                   |   4 +
 .../buildsrc/GrailsGroovydocExtension.groovy       |  67 ++++++++
 .../grails/buildsrc/GrailsGroovydocPlugin.groovy   | 186 +++++++++++++++++++++
 gradle/docs-config.gradle                          |   9 -
 gradle/docs-dependencies.gradle                    | 121 +-------------
 grails-data-docs/stage/build.gradle                |  17 --
 grails-data-hibernate5/docs/build.gradle           |  81 +--------
 grails-data-mongodb/docs/build.gradle              |  82 +--------
 grails-doc/build.gradle                            |  17 --
 grails-forge/gradle/doc-config.gradle              |  93 +----------
 grails-gradle/gradle/docs-config.gradle            |  89 +---------
 11 files changed, 275 insertions(+), 491 deletions(-)

diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle
index e85254e152..f750808908 100644
--- a/build-logic/plugins/build.gradle
+++ b/build-logic/plugins/build.gradle
@@ -62,6 +62,10 @@ gradlePlugin {
             id = 'org.apache.grails.gradle.grails-code-style'
             implementationClass = 'org.apache.grails.buildsrc.GrailsCodeStylePlugin'
         }
+        register('grailsGroovydoc') {
+            id = 'org.apache.grails.buildsrc.groovydoc'
+            implementationClass = 'org.apache.grails.buildsrc.GrailsGroovydocPlugin'
+        }
         register('grailsRepoSettings') {
             id = 'org.apache.grails.buildsrc.repo'
             implementationClass = 'org.apache.grails.buildsrc.GrailsRepoSettingsPlugin'
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsGroovydocExtension.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsGroovydocExtension.groovy
new file mode 100644
index 0000000000..98cefd434c
--- /dev/null
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsGroovydocExtension.groovy
@@ -0,0 +1,67 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import javax.inject.Inject
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.Project
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.Property
+
+/**
+ * Extension for configuring the Grails Groovydoc convention plugin.
+ *
+ * <p>Allows per-project control over the {@code javaVersion} parameter
+ * passed to the Groovy Ant groovydoc task. The {@code javaVersion}
+ * parameter was added in Groovy 4.0.27 (GROOVY-11668) and controls
+ * the JavaParser language level used when parsing Java sources.</p>
+ *
+ * @since 7.0.8
+ */
+@CompileStatic
+class GrailsGroovydocExtension {
+
+    /**
+     * The Java language level string passed to the groovydoc Ant task's
+     * {@code javaVersion} parameter (e.g. {@code "JAVA_17"}, {@code "JAVA_21"}).
+     *
+     * <p>Defaults to {@code "JAVA_${javaVersion}"} where {@code javaVersion}
+     * is read from the project property, falling back to {@code "JAVA_17"}.</p>
+     */
+    final Property<String> javaVersion
+
+    /**
+     * Whether to pass the {@code javaVersion} parameter to the groovydoc
+     * Ant task. Set to {@code false} for projects using Groovy versions
+     * older than 4.0.27 (which do not support the parameter).
+     *
+     * <p>Defaults to {@code true}.</p>
+     */
+    final Property<Boolean> javaVersionEnabled
+
+    @Inject
+    GrailsGroovydocExtension(ObjectFactory objects, Project project) {
+        javaVersion = objects.property(String).convention(
+                "JAVA_${GradleUtils.findProperty(project, 'javaVersion') ?: '17'}" as String
+        )
+        javaVersionEnabled = objects.property(Boolean).convention(true)
+    }
+}
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsGroovydocPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsGroovydocPlugin.groovy
new file mode 100644
index 0000000000..d5e5875af2
--- /dev/null
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsGroovydocPlugin.groovy
@@ -0,0 +1,186 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.attributes.Bundling
+import org.gradle.api.attributes.Category
+import org.gradle.api.attributes.Usage
+import org.gradle.api.provider.Provider
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.javadoc.Groovydoc
+
+@CompileStatic
+class GrailsGroovydocPlugin implements Plugin<Project> {
+
+    static final String MATOMO_FOOTER = '''\
+<!-- Matomo -->
+<script>
+    var _paq = window._paq = window._paq || [];
+    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
+    _paq.push(["setDoNotTrack", true]);
+    _paq.push(["disableCookies"]);
+    _paq.push(['trackPageView']);
+    _paq.push(['enableLinkTracking']);
+    (function() {
+        var u="https://analytics.apache.org/";
+        _paq.push(['setTrackerUrl', u+'matomo.php']);
+        _paq.push(['setSiteId', '79']);
+        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
+        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
+    })();
+</script>
+<!-- End Matomo Code -->'''
+
+    @Override
+    void apply(Project project) {
+        GrailsGroovydocExtension extension = project.extensions.create(
+                'grailsGroovydoc', GrailsGroovydocExtension, project
+        )
+        registerDocumentationConfiguration(project)
+        configureGroovydocDefaults(project)
+        configureAntBuilderExecution(project, extension)
+    }
+
+    private static void registerDocumentationConfiguration(Project project) {
+        if (project.configurations.names.contains('documentation')) {
+            return
+        }
+        project.configurations.register('documentation') { Configuration config ->
+            config.canBeConsumed = false
+            config.canBeResolved = true
+            config.attributes { container ->
+                container.attribute(Category.CATEGORY_ATTRIBUTE, project.objects.named(Category, Category.LIBRARY))
+                container.attribute(Bundling.BUNDLING_ATTRIBUTE, project.objects.named(Bundling, Bundling.EXTERNAL))
+                container.attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage, Usage.JAVA_RUNTIME))
+            }
+        }
+    }
+
+    @CompileDynamic
+    private static void configureGroovydocDefaults(Project project) {
+        project.tasks.withType(Groovydoc).configureEach { Groovydoc gdoc ->
+            gdoc.includeAuthor = false
+            gdoc.includeMainForScripts = false
+            gdoc.processScripts = false
+            gdoc.noTimestamp = true
+            gdoc.noVersionStamp = false
+            gdoc.footer = MATOMO_FOOTER
+            if (project.configurations.names.contains('documentation')) {
+                gdoc.groovyClasspath = project.configurations.getByName('documentation')
+            }
+        }
+    }
+
+    @CompileDynamic
+    private static void configureAntBuilderExecution(Project project, GrailsGroovydocExtension extension) {
+        project.tasks.withType(Groovydoc).configureEach { Groovydoc gdoc ->
+            gdoc.actions.clear()
+            gdoc.doLast {
+                File destDir = gdoc.destinationDir
+                destDir.mkdirs()
+
+                List<File> sourceDirs = resolveSourceDirectories(gdoc, project)
+                if (sourceDirs.isEmpty()) {
+                    project.logger.lifecycle("Skipping groovydoc for ${gdoc.name}: no source directories found")
+                    return
+                }
+
+                Configuration docConfig = project.configurations.findByName('documentation')
+                if (!docConfig) {
+                    project.logger.warn("Skipping groovydoc for ${gdoc.name}: 'documentation' configuration not found")
+                    return
+                }
+
+                project.ant.taskdef(
+                        name: 'groovydoc',
+                        classname: 'org.codehaus.groovy.ant.Groovydoc',
+                        classpath: docConfig.asPath
+                )
+
+                List<Map<String, String>> links = resolveLinks(gdoc)
+                String sourcepath = sourceDirs.collect { it.absolutePath }.join(File.pathSeparator)
+
+                Map<String, Object> antArgs = [
+                        destdir: destDir.absolutePath,
+                        sourcepath: sourcepath,
+                        packagenames: '**.*',
+                        windowtitle: gdoc.windowTitle ?: '',
+                        doctitle: gdoc.docTitle ?: '',
+                        footer: gdoc.footer ?: '',
+                        access: resolveGroovydocProperty(gdoc.access)?.name()?.toLowerCase() ?: 'protected',
+                        author: resolveGroovydocProperty(gdoc.includeAuthor) as String,
+                        noTimestamp: resolveGroovydocProperty(gdoc.noTimestamp) as String,
+                        noVersionStamp: resolveGroovydocProperty(gdoc.noVersionStamp) as String,
+                        processScripts: resolveGroovydocProperty(gdoc.processScripts) as String,
+                        includeMainForScripts: resolveGroovydocProperty(gdoc.includeMainForScripts) as String
+                ]
+
+                if (extension.javaVersionEnabled.get()) {
+                    antArgs.put('javaVersion', extension.javaVersion.get())
+                }
+
+                project.ant.groovydoc(antArgs) {
+                    for (Map<String, String> l in links) {
+                        link(packages: l.packages, href: l.href)
+                    }
+                }
+            }
+        }
+    }
+
+    @CompileDynamic
+    private static List<File> resolveSourceDirectories(Groovydoc gdoc, Project project) {
+        if (gdoc.ext.has('groovydocSourceDirs') && gdoc.ext.groovydocSourceDirs) {
+            return (gdoc.ext.groovydocSourceDirs as List<File>).findAll { it.exists() }.unique() as List<File>
+        }
+
+        List<File> sourceDirs = []
+        SourceSetContainer sourceSets = project.extensions.findByType(SourceSetContainer)
+        if (sourceSets) {
+            SourceSet mainSS = sourceSets.findByName('main')
+            if (mainSS) {
+                sourceDirs.addAll(mainSS.groovy.srcDirs.findAll { it.exists() })
+                sourceDirs.addAll(mainSS.java.srcDirs.findAll { it.exists() })
+            }
+        }
+        sourceDirs.unique() as List<File>
+    }
+
+    @CompileDynamic
+    private static List<Map<String, String>> resolveLinks(Groovydoc gdoc) {
+        if (gdoc.ext.has('groovydocLinks')) {
+            return gdoc.ext.groovydocLinks as List<Map<String, String>>
+        }
+        []
+    }
+
+    static Object resolveGroovydocProperty(Object value) {
+        if (value instanceof Provider) {
+            return ((Provider) value).getOrNull()
+        }
+        value
+    }
+}
diff --git a/gradle/docs-config.gradle b/gradle/docs-config.gradle
index 76a14b619b..5b5c7d1794 100644
--- a/gradle/docs-config.gradle
+++ b/gradle/docs-config.gradle
@@ -21,13 +21,4 @@ apply from: rootProject.layout.projectDirectory.file('gradle/docs-dependencies.g
 
 ext {
     includeInApiDocs = true
-}
-
-// Set source directories for AntBuilder groovydoc execution (per-module tasks)
-tasks.withType(Groovydoc).configureEach { Groovydoc gdoc ->
-    SourceSetContainer sourceSets = project.extensions.getByType(SourceSetContainer)
-    SourceSet mainSS = sourceSets.findByName('main')
-    if (mainSS) {
-        gdoc.ext.groovydocSourceDirs = (mainSS.groovy.srcDirs + mainSS.java.srcDirs).findAll { it.exists() }
-    }
 }
\ No newline at end of file
diff --git a/gradle/docs-dependencies.gradle b/gradle/docs-dependencies.gradle
index 23b75693b9..afb56aec4a 100644
--- a/gradle/docs-dependencies.gradle
+++ b/gradle/docs-dependencies.gradle
@@ -16,15 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-configurations.register('documentation') {
-    canBeConsumed = false
-    canBeResolved = true
-    attributes {
-        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
-        attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
-        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
-    }
-}
+apply plugin: 'org.apache.grails.buildsrc.groovydoc'
 
 dependencies {
     add('documentation', platform(project(':grails-bom')))
@@ -50,127 +42,32 @@ String resolveProjectVersion(String artifact) {
     }
 }
 
-// Resolve a Groovydoc task property that may be either a Gradle Property<T> or a plain value.
-// Gradle 8.x Groovydoc uses a mix of both types depending on the property.
-Object resolveGroovydocProperty(Object value) {
-    if (value instanceof org.gradle.api.provider.Provider) {
-        return value.getOrNull()
-    }
-    return value
-}
-
-// Compute the javaVersion string for groovydoc's JavaParser language level.
-// Requires Groovy 4.0.27+ (GROOVY-11668). Gradle's built-in Groovydoc task does not
-// expose this parameter (https://github.com/gradle/gradle/issues/33659), so we replace
-// the task execution with AntBuilder to pass it directly to the Groovy Ant task.
-String groovydocJavaVersion = "JAVA_${project.findProperty('javaVersion') ?: '17'}"
-
 tasks.withType(Groovydoc).configureEach { Groovydoc gdoc ->
     gdoc.exclude('META-INF/**', '*yml', '*properties', '*xml', '**/Application.groovy', '**/Bootstrap.groovy', '**/resources.groovy')
-    gdoc.groovyClasspath = configurations.documentation
     gdoc.windowTitle = "${project.findProperty('pomArtifactId') ?: project.name} - $projectVersion"
     gdoc.docTitle = "${project.findProperty('pomArtifactId') ?: project.name} - $projectVersion"
-    gdoc.access = GroovydocAccess.PROTECTED
-    gdoc.includeAuthor = false
-    gdoc.includeMainForScripts = false
-    gdoc.processScripts = false
-    gdoc.noTimestamp = true
-    gdoc.noVersionStamp = false
-    gdoc.footer = '''<!-- Matomo -->
-<script>
-    var _paq = window._paq = window._paq || [];
-    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
-    _paq.push(["setDoNotTrack", true]);
-    _paq.push(["disableCookies"]);
-    _paq.push(['trackPageView']);
-    _paq.push(['enableLinkTracking']);
-    (function() {
-        var u="https://analytics.apache.org/";
-        _paq.push(['setTrackerUrl', u+'matomo.php']);
-        _paq.push(['setSiteId', '79']);
-        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
-        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-    })();
-</script>
-<!-- End Matomo Code -->'''
 
-    // Replace Gradle's built-in Groovydoc execution with AntBuilder to support javaVersion.
-    // See: https://github.com/apache/grails-core/issues/15385
-    gdoc.actions.clear()
-    gdoc.doLast {
-        def destDir = gdoc.destinationDir
-        destDir.mkdirs()
-
-        // Source directories: set by modules via ext.groovydocSourceDirs, or derived from source sets
-        List<File> sourceDirs
-        if (gdoc.ext.has('groovydocSourceDirs') && gdoc.ext.groovydocSourceDirs) {
-            sourceDirs = (gdoc.ext.groovydocSourceDirs as List<File>).findAll { it.exists() }
-        } else {
-            sourceDirs = []
-            def sourceSetsExt = project.extensions.findByType(SourceSetContainer)
-            if (sourceSetsExt) {
-                def mainSS = sourceSetsExt.findByName('main')
-                if (mainSS) {
-                    sourceDirs.addAll(mainSS.groovy.srcDirs.findAll { it.exists() })
-                    sourceDirs.addAll(mainSS.java.srcDirs.findAll { it.exists() })
-                }
-            }
-        }
-        sourceDirs = sourceDirs.unique()
-
-        if (sourceDirs.isEmpty()) {
-            logger.lifecycle("Skipping groovydoc for ${gdoc.name}: no source directories found")
-            return
-        }
-
-        ant.taskdef(
-                name: 'groovydoc',
-                classname: 'org.codehaus.groovy.ant.Groovydoc',
-                classpath: configurations.documentation.asPath
-        )
-
-        // Resolve dynamic links from project dependencies
-        List<Map<String, String>> groovydocLinks = []
+    gdoc.doFirst {
+        List<Map<String, String>> links = []
         def gebVersion = resolveProjectVersion('geb-spock')
         if (gebVersion) {
-            groovydocLinks << [packages: 'geb.', href: "https://groovy.apache.org/geb/manual/${gebVersion}/api/"]
+            links << [packages: 'geb.', href: "https://groovy.apache.org/geb/manual/${gebVersion}/api/"]
         }
         def testContainersVersion = resolveProjectVersion('testcontainers')
         if (testContainersVersion) {
-            groovydocLinks << [packages: 'org.testcontainers.', href: "https://javadoc.io/doc/org.testcontainers/testcontainers/${testContainersVersion}/"]
+            links << [packages: 'org.testcontainers.', href: "https://javadoc.io/doc/org.testcontainers/testcontainers/${testContainersVersion}/"]
         }
         def springVersion = resolveProjectVersion('spring-core')
         if (springVersion) {
-            groovydocLinks << [packages: 'org.springframework.core.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"]
+            links << [packages: 'org.springframework.core.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"]
         }
         def springBootVersion = resolveProjectVersion('spring-boot')
         if (springBootVersion) {
-            groovydocLinks << [packages: 'org.springframework.boot.', href: "https://docs.spring.io/spring-boot/docs/${springBootVersion}/api/"]
+            links << [packages: 'org.springframework.boot.', href: "https://docs.spring.io/spring-boot/docs/${springBootVersion}/api/"]
         }
         if (gdoc.ext.has('groovydocLinks')) {
-            groovydocLinks.addAll(gdoc.ext.groovydocLinks as List<Map<String, String>>)
-        }
-
-        def sourcepath = sourceDirs.collect { it.absolutePath }.join(File.pathSeparator)
-
-        ant.groovydoc(
-                destdir: destDir.absolutePath,
-                sourcepath: sourcepath,
-                packagenames: '**.*',
-                windowtitle: gdoc.windowTitle ?: '',
-                doctitle: gdoc.docTitle ?: '',
-                footer: gdoc.footer ?: '',
-                access: resolveGroovydocProperty(gdoc.access)?.name()?.toLowerCase() ?: 'protected',
-                author: resolveGroovydocProperty(gdoc.includeAuthor) as String,
-                noTimestamp: resolveGroovydocProperty(gdoc.noTimestamp) as String,
-                noVersionStamp: resolveGroovydocProperty(gdoc.noVersionStamp) as String,
-                processScripts: resolveGroovydocProperty(gdoc.processScripts) as String,
-                includeMainForScripts: resolveGroovydocProperty(gdoc.includeMainForScripts) as String,
-                javaVersion: groovydocJavaVersion
-        ) {
-            for (Map<String, String> l in groovydocLinks) {
-                link(packages: l.packages, href: l.href)
-            }
+            links.addAll(gdoc.ext.groovydocLinks as List<Map<String, String>>)
         }
+        gdoc.ext.groovydocLinks = links
     }
 }
\ No newline at end of file
diff --git a/grails-data-docs/stage/build.gradle b/grails-data-docs/stage/build.gradle
index 4d13bb6b01..5af7d88544 100644
--- a/grails-data-docs/stage/build.gradle
+++ b/grails-data-docs/stage/build.gradle
@@ -25,23 +25,6 @@ apply from: rootProject.layout.projectDirectory.file('gradle/docs-dependencies.g
 combinedGroovydoc.configure { Groovydoc task ->
     task.windowTitle = "Grails Data Mapping API - ${projectVersion}"
     task.docTitle = "Grails Data Mapping API - ${projectVersion}"
-    task.footer = '''<!-- Matomo -->
-<script>
-    var _paq = window._paq = window._paq || [];
-    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
-    _paq.push(["setDoNotTrack", true]);
-    _paq.push(["disableCookies"]);
-    _paq.push(['trackPageView']);
-    _paq.push(['enableLinkTracking']);
-    (function() {
-        var u="https://analytics.apache.org/";
-        _paq.push(['setTrackerUrl', u+'matomo.php']);
-        _paq.push(['setSiteId', '79']);
-        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
-        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-    })();
-</script>
-<!-- End Matomo Code -->'''
 
     Set<Project> docProjects = rootProject.subprojects.findAll {
         it.name in [
diff --git a/grails-data-hibernate5/docs/build.gradle b/grails-data-hibernate5/docs/build.gradle
index 565da447cb..be9c04f90d 100644
--- a/grails-data-hibernate5/docs/build.gradle
+++ b/grails-data-hibernate5/docs/build.gradle
@@ -31,13 +31,7 @@ ext {
     coreProjects = ['grails-datastore-core', 'grails-datamapping-core']
 }
 
-configurations {
-    documentation {
-        attributes {
-            attribute(Bundling.BUNDLING_ATTRIBUTE, (Bundling) (objects.named(Bundling, 'external')))
-        }
-    }
-}
+apply plugin: 'org.apache.grails.buildsrc.groovydoc'
 
 dependencies {
     documentation platform(project(':grails-bom'))
@@ -94,41 +88,12 @@ tasks.named('asciidoctor', AsciidoctorTask) { AsciidoctorTask it ->
     ]
 }
 
-// Resolve a Groovydoc task property that may be either a Gradle Property<T> or a plain value.
-Object resolveGroovydocProperty(Object value) {
-    if (value instanceof org.gradle.api.provider.Provider) {
-        return value.getOrNull()
-    }
-    return value
-}
-
-// Compute the javaVersion string for groovydoc's JavaParser language level.
-// Requires Groovy 4.0.27+ (GROOVY-11668).
-String groovydocJavaVersion = "JAVA_${project.findProperty('javaVersion') ?: '17'}"
-
 tasks.withType(Groovydoc).configureEach {
     it.dependsOn(rootProject.subprojects
             .findAll { it.findProperty('gormApiDocs') }
             .collect { ":${it.name}:groovydoc" })
 
     it.docTitle = "GORM for Hibernate 5 - $project.version"
-    it.footer = '''<!-- Matomo -->
-<script>
-    var _paq = window._paq = window._paq || [];
-    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
-    _paq.push(["setDoNotTrack", true]);
-    _paq.push(["disableCookies"]);
-    _paq.push(['trackPageView']);
-    _paq.push(['enableLinkTracking']);
-    (function() {
-        var u="https://analytics.apache.org/";
-        _paq.push(['setTrackerUrl', u+'matomo.php']);
-        _paq.push(['setSiteId', '79']);
-        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
-        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-    })();
-</script>
-<!-- End Matomo Code -->'''
 
     def sourceFiles = coreProjects.collect {
         rootProject.layout.projectDirectory.files("$it/src/main/groovy")
@@ -140,15 +105,8 @@ tasks.withType(Groovydoc).configureEach {
 
     it.source = sourceFiles
     it.destinationDir = layout.buildDirectory.dir('combined-api/api').get().asFile
-    it.access = GroovydocAccess.PROTECTED
-    it.processScripts = false
-    it.includeMainForScripts = false
-    it.includeAuthor = false
     it.classpath = configurations.documentation
-    it.groovyClasspath += configurations.documentation
-    it.noVersionStamp = false
 
-    // Collect source directories for AntBuilder groovydoc
     List<File> groovydocSrcDirs = coreProjects.collect {
         rootProject.layout.projectDirectory.dir("$it/src/main/groovy").asFile
     }
@@ -156,43 +114,6 @@ tasks.withType(Groovydoc).configureEach {
             .findAll { sp -> sp.findProperty('gormApiDocs') }
             .each { sp -> groovydocSrcDirs << new File(sp.projectDir, 'src/main/groovy') }
     it.ext.groovydocSourceDirs = groovydocSrcDirs
-
-    // Replace Gradle's built-in Groovydoc execution with AntBuilder to support javaVersion.
-    // See: https://github.com/apache/grails-core/issues/15385
-    it.actions.clear()
-    it.doLast { Groovydoc gdoc ->
-        def destDir = gdoc.destinationDir
-        destDir.mkdirs()
-
-        List<File> sourceDirs = (gdoc.ext.groovydocSourceDirs as List<File>).findAll { File f -> f.exists() }.unique()
-        if (sourceDirs.isEmpty()) {
-            logger.lifecycle("Skipping groovydoc for ${gdoc.name}: no source directories found")
-            return
-        }
-
-        ant.taskdef(
-                name: 'groovydoc',
-                classname: 'org.codehaus.groovy.ant.Groovydoc',
-                classpath: configurations.documentation.asPath
-        )
-
-        def sourcepath = sourceDirs.collect { File f -> f.absolutePath }.join(File.pathSeparator)
-
-        ant.groovydoc(
-                destdir: destDir.absolutePath,
-                sourcepath: sourcepath,
-                packagenames: '**.*',
-                doctitle: gdoc.docTitle ?: '',
-                footer: gdoc.footer ?: '',
-                access: resolveGroovydocProperty(gdoc.access)?.name()?.toLowerCase() ?: 'protected',
-                author: resolveGroovydocProperty(gdoc.includeAuthor) as String,
-                noTimestamp: 'true',
-                noVersionStamp: resolveGroovydocProperty(gdoc.noVersionStamp) as String,
-                processScripts: resolveGroovydocProperty(gdoc.processScripts) as String,
-                includeMainForScripts: resolveGroovydocProperty(gdoc.includeMainForScripts) as String,
-                javaVersion: groovydocJavaVersion
-        )
-    }
 }
 
 tasks.register('docs', Sync).configure { Sync docTask ->
diff --git a/grails-data-mongodb/docs/build.gradle b/grails-data-mongodb/docs/build.gradle
index 81a0fca998..02de80eb95 100644
--- a/grails-data-mongodb/docs/build.gradle
+++ b/grails-data-mongodb/docs/build.gradle
@@ -31,13 +31,7 @@ ext {
     coreProjects = ['grails-datastore-core', 'grails-datamapping-core']
 }
 
-configurations {
-    documentation {
-        attributes {
-            attribute(Bundling.BUNDLING_ATTRIBUTE, (Bundling) (objects.named(Bundling, 'external')))
-        }
-    }
-}
+apply plugin: 'org.apache.grails.buildsrc.groovydoc'
 
 tasks.register('resolveMongodbVersion').configure { Task docTask ->
     docTask.group = 'documentation'
@@ -103,40 +97,12 @@ tasks.named('asciidoctor', AsciidoctorTask) { AsciidoctorTask it ->
     ]
 }
 
-// Compute the javaVersion string for groovydoc's JavaParser language level.
-// Requires Groovy 4.0.27+ (GROOVY-11668).
-String groovydocJavaVersion = "JAVA_${project.findProperty('javaVersion') ?: '17'}"
-
-// Resolve a Groovydoc task property that may be either a Gradle Property<T> or a plain value.
-Object resolveGroovydocProperty(Object value) {
-    if (value instanceof org.gradle.api.provider.Provider) {
-        return value.getOrNull()
-    }
-    return value
-}
-
 tasks.withType(Groovydoc).configureEach { Groovydoc groovydoc ->
     groovydoc.dependsOn(rootProject.subprojects
             .findAll { it.findProperty('gormApiDocs') }
             .collect { ":${it.name}:groovydoc" })
     groovydoc.docTitle = "GORM for MongoDB - $project.version"
-    groovydoc.footer = '''<!-- Matomo -->
-<script>
-    var _paq = window._paq = window._paq || [];
-    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
-    _paq.push(["setDoNotTrack", true]);
-    _paq.push(["disableCookies"]);
-    _paq.push(['trackPageView']);
-    _paq.push(['enableLinkTracking']);
-    (function() {
-        var u="https://analytics.apache.org/";
-        _paq.push(['setTrackerUrl', u+'matomo.php']);
-        _paq.push(['setSiteId', '79']);
-        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
-        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-    })();
-</script>
-<!-- End Matomo Code -->'''
+    groovydoc.includeAuthor = true
 
     def sourceFiles = coreProjects.collect {
         layout.projectDirectory.files("$it/src/main/groovy")
@@ -148,15 +114,8 @@ tasks.withType(Groovydoc).configureEach { Groovydoc groovydoc ->
 
     groovydoc.source = sourceFiles
     groovydoc.destinationDir = layout.buildDirectory.dir('combined-api/api').get().asFile
-    groovydoc.access = GroovydocAccess.PROTECTED
-    groovydoc.processScripts = false
-    groovydoc.includeMainForScripts = false
-    groovydoc.includeAuthor = true
     groovydoc.classpath = configurations.documentation
-    groovydoc.groovyClasspath += configurations.documentation
-    groovydoc.noVersionStamp = false
 
-    // Collect source directories for AntBuilder groovydoc
     List<File> groovydocSrcDirs = coreProjects.collect {
         layout.projectDirectory.dir("$it/src/main/groovy").asFile
     }
@@ -164,43 +123,6 @@ tasks.withType(Groovydoc).configureEach { Groovydoc groovydoc ->
             .findAll { sp -> sp.findProperty('gormApiDocs') }
             .each { sp -> groovydocSrcDirs << new File(sp.projectDir, 'src/main/groovy') }
     groovydoc.ext.groovydocSourceDirs = groovydocSrcDirs
-
-    // Replace Gradle's built-in Groovydoc execution with AntBuilder to support javaVersion.
-    // See: https://github.com/apache/grails-core/issues/15385
-    groovydoc.actions.clear()
-    groovydoc.doLast { Groovydoc gdoc ->
-        def destDir = gdoc.destinationDir
-        destDir.mkdirs()
-
-        List<File> sourceDirs = (gdoc.ext.groovydocSourceDirs as List<File>).findAll { File f -> f.exists() }.unique()
-        if (sourceDirs.isEmpty()) {
-            logger.lifecycle("Skipping groovydoc for ${gdoc.name}: no source directories found")
-            return
-        }
-
-        ant.taskdef(
-                name: 'groovydoc',
-                classname: 'org.codehaus.groovy.ant.Groovydoc',
-                classpath: configurations.documentation.asPath
-        )
-
-        def sourcepath = sourceDirs.collect { File f -> f.absolutePath }.join(File.pathSeparator)
-
-        ant.groovydoc(
-                destdir: destDir.absolutePath,
-                sourcepath: sourcepath,
-                packagenames: '**.*',
-                doctitle: gdoc.docTitle ?: '',
-                footer: gdoc.footer ?: '',
-                access: resolveGroovydocProperty(gdoc.access)?.name()?.toLowerCase() ?: 'protected',
-                author: resolveGroovydocProperty(gdoc.includeAuthor) as String,
-                noTimestamp: 'true',
-                noVersionStamp: resolveGroovydocProperty(gdoc.noVersionStamp) as String,
-                processScripts: resolveGroovydocProperty(gdoc.processScripts) as String,
-                includeMainForScripts: resolveGroovydocProperty(gdoc.includeMainForScripts) as String,
-                javaVersion: groovydocJavaVersion
-        )
-    }
 }
 
 tasks.register('docs', Sync).configure { Sync docTask ->
diff --git a/grails-doc/build.gradle b/grails-doc/build.gradle
index 63382c712e..4f7c1d9d87 100644
--- a/grails-doc/build.gradle
+++ b/grails-doc/build.gradle
@@ -63,23 +63,6 @@ apply from: rootProject.layout.projectDirectory.file('gradle/docs-dependencies.g
 combinedGroovydoc.configure { Groovydoc gdoc ->
     gdoc.windowTitle = "Grails $projectVersion"
     gdoc.docTitle = "Grails $projectVersion"
-    gdoc.footer = '''<!-- Matomo -->
-<script>
-    var _paq = window._paq = window._paq || [];
-    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
-    _paq.push(["setDoNotTrack", true]);
-    _paq.push(["disableCookies"]);
-    _paq.push(['trackPageView']);
-    _paq.push(['enableLinkTracking']);
-    (function() {
-        var u="https://analytics.apache.org/";
-        _paq.push(['setTrackerUrl', u+'matomo.php']);
-        _paq.push(['setSiteId', '79']);
-        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
-        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-    })();
-</script>
-<!-- End Matomo Code -->'''
 
     def docProjects = rootProject.subprojects
             .findAll { it.findProperty('includeInApiDocs') }
diff --git a/grails-forge/gradle/doc-config.gradle b/grails-forge/gradle/doc-config.gradle
index 38b8e2a60d..4d22d4fba6 100644
--- a/grails-forge/gradle/doc-config.gradle
+++ b/grails-forge/gradle/doc-config.gradle
@@ -17,14 +17,10 @@
  *  under the License.
  */
 
-configurations.register('documentation') {
-    canBeConsumed = false
-    canBeResolved = true
-    attributes {
-        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
-        attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
-        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
-    }
+apply plugin: 'org.apache.grails.buildsrc.groovydoc'
+
+grailsGroovydoc {
+    javaVersionEnabled = false
 }
 
 dependencies {
@@ -38,85 +34,4 @@ tasks.withType(Groovydoc).configureEach { Groovydoc gdoc ->
     gdoc.classpath += project.configurations.documentation
     gdoc.windowTitle = "${project.findProperty('pomArtifactId') ?: project.name} - $projectVersion"
     gdoc.docTitle = "${project.findProperty('pomArtifactId') ?: project.name} - $projectVersion"
-    gdoc.footer = '''<!-- Matomo -->
-<script>
-    var _paq = window._paq = window._paq || [];
-    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
-    _paq.push(["setDoNotTrack", true]);
-    _paq.push(["disableCookies"]);
-    _paq.push(['trackPageView']);
-    _paq.push(['enableLinkTracking']);
-    (function() {
-        var u="https://analytics.apache.org/";
-        _paq.push(['setTrackerUrl', u+'matomo.php']);
-        _paq.push(['setSiteId', '79']);
-        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
-        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-    })();
-</script>
-<!-- End Matomo Code -->'''
-    gdoc.access = GroovydocAccess.PROTECTED
-    gdoc.includeAuthor = false
-    gdoc.includeMainForScripts = false
-    gdoc.processScripts = false
-    gdoc.noTimestamp = true
-    gdoc.noVersionStamp = false
-
-    // Resolve a Groovydoc task property that may be either a Gradle Property<T> or a plain value.
-    Closure resolveGroovydocProperty = { Object value ->
-        if (value instanceof org.gradle.api.provider.Provider) {
-            return value.getOrNull()
-        }
-        return value
-    }
-
-    // Replace Gradle's built-in Groovydoc execution with AntBuilder for consistency
-    // with the main build. The javaVersion parameter is not available in Groovy 3.x
-    // (requires Groovy 4.0.27+, GROOVY-11668) - it will be added when forge upgrades.
-    // See: https://github.com/apache/grails-core/issues/15385
-    gdoc.actions.clear()
-    gdoc.doLast {
-        def destDir = gdoc.destinationDir
-        destDir.mkdirs()
-
-        List<File> sourceDirs = []
-        def sourceSetsExt = project.extensions.findByType(SourceSetContainer)
-        if (sourceSetsExt) {
-            def mainSS = sourceSetsExt.findByName('main')
-            if (mainSS) {
-                sourceDirs.addAll(mainSS.groovy.srcDirs.findAll { it.exists() })
-                sourceDirs.addAll(mainSS.java.srcDirs.findAll { it.exists() })
-            }
-        }
-        sourceDirs = sourceDirs.unique()
-
-        if (sourceDirs.isEmpty()) {
-            logger.lifecycle("Skipping groovydoc for ${gdoc.name}: no source directories found")
-            return
-        }
-
-        ant.taskdef(
-                name: 'groovydoc',
-                classname: 'org.codehaus.groovy.ant.Groovydoc',
-                classpath: configurations.documentation.asPath
-        )
-
-        def sourcepath = sourceDirs.collect { File f -> f.absolutePath }.join(File.pathSeparator)
-
-        // Note: javaVersion omitted - not supported in Groovy 3.x (forge uses Groovy $groovyVersion)
-        ant.groovydoc(
-                destdir: destDir.absolutePath,
-                sourcepath: sourcepath,
-                packagenames: '**.*',
-                windowtitle: gdoc.windowTitle ?: '',
-                doctitle: gdoc.docTitle ?: '',
-                footer: gdoc.footer ?: '',
-                access: resolveGroovydocProperty(gdoc.access)?.name()?.toLowerCase() ?: 'protected',
-                author: resolveGroovydocProperty(gdoc.includeAuthor) as String,
-                noTimestamp: resolveGroovydocProperty(gdoc.noTimestamp) as String,
-                noVersionStamp: resolveGroovydocProperty(gdoc.noVersionStamp) as String,
-                processScripts: resolveGroovydocProperty(gdoc.processScripts) as String,
-                includeMainForScripts: resolveGroovydocProperty(gdoc.includeMainForScripts) as String
-        )
-    }
 }
diff --git a/grails-gradle/gradle/docs-config.gradle b/grails-gradle/gradle/docs-config.gradle
index e3459998b6..8426b70a63 100644
--- a/grails-gradle/gradle/docs-config.gradle
+++ b/grails-gradle/gradle/docs-config.gradle
@@ -17,15 +17,8 @@
  *  under the License.
  */
 
-configurations.register('documentation') {
-    canBeConsumed = false
-    canBeResolved = true
-    attributes {
-        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
-        attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
-        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
-    }
-}
+apply plugin: 'org.apache.grails.buildsrc.groovydoc'
+
 dependencies {
     add('documentation', platform(project(':grails-gradle-bom')))
     add('documentation', 'org.fusesource.jansi:jansi')
@@ -42,88 +35,10 @@ ext {
     includeInApiDocs = true
 }
 
-// Resolve a Groovydoc task property that may be either a Gradle Property<T> or a plain value.
-Object resolveGroovydocProperty(Object value) {
-    if (value instanceof org.gradle.api.provider.Provider) {
-        return value.getOrNull()
-    }
-    return value
-}
-
-// Compute the javaVersion string for groovydoc's JavaParser language level.
-// Requires Groovy 4.0.27+ (GROOVY-11668).
-String groovydocJavaVersion = "JAVA_${project.findProperty('javaVersion') ?: '17'}"
-
 TaskProvider<Groovydoc> groovydocTask = tasks.named('groovydoc', Groovydoc)
 groovydocTask.configure { Groovydoc it ->
     it.classpath = configurations.documentation
-    it.groovyClasspath = configurations.documentation
-    it.access = GroovydocAccess.PROTECTED
-    it.includeAuthor = false
-    it.includeMainForScripts = false
-    it.processScripts = false
-    it.noTimestamp = true
-    it.noVersionStamp = false
-    it.footer = '''<!-- Matomo -->
-<script>
-    var _paq = window._paq = window._paq || [];
-    /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
-    _paq.push(["setDoNotTrack", true]);
-    _paq.push(["disableCookies"]);
-    _paq.push(['trackPageView']);
-    _paq.push(['enableLinkTracking']);
-    (function() {
-        var u="https://analytics.apache.org/";
-        _paq.push(['setTrackerUrl', u+'matomo.php']);
-        _paq.push(['setSiteId', '79']);
-        var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
-        g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
-    })();
-</script>
-<!-- End Matomo Code -->'''
     it.destinationDir = project.file('build/docs/api')
-
-    // Replace Gradle's built-in Groovydoc execution with AntBuilder to support javaVersion.
-    // See: https://github.com/apache/grails-core/issues/15385
-    it.actions.clear()
-    it.doLast {
-        def destDir = it.destinationDir
-        destDir.mkdirs()
-
-        SourceSetContainer sourceSets = project.extensions.getByType(SourceSetContainer)
-        List<File> sourceDirs = ([] + sourceSets.main.groovy.srcDirs + sourceSets.main.java.srcDirs)
-                .findAll { File f -> f.exists() }
-                .unique()
-
-        if (sourceDirs.isEmpty()) {
-            logger.lifecycle("Skipping groovydoc for ${it.name}: no source directories found")
-            return
-        }
-
-        ant.taskdef(
-                name: 'groovydoc',
-                classname: 'org.codehaus.groovy.ant.Groovydoc',
-                classpath: configurations.documentation.asPath
-        )
-
-        def sourcepath = sourceDirs.collect { File f -> f.absolutePath }.join(File.pathSeparator)
-
-        ant.groovydoc(
-                destdir: destDir.absolutePath,
-                sourcepath: sourcepath,
-                packagenames: '**.*',
-                windowtitle: it.windowTitle ?: '',
-                doctitle: it.docTitle ?: '',
-                footer: it.footer ?: '',
-                access: resolveGroovydocProperty(it.access)?.name()?.toLowerCase() ?: 'protected',
-                author: resolveGroovydocProperty(it.includeAuthor) as String,
-                noTimestamp: resolveGroovydocProperty(it.noTimestamp) as String,
-                noVersionStamp: resolveGroovydocProperty(it.noVersionStamp) as String,
-                processScripts: resolveGroovydocProperty(it.processScripts) as String,
-                includeMainForScripts: resolveGroovydocProperty(it.includeMainForScripts) as String,
-                javaVersion: groovydocJavaVersion
-        )
-    }
 }
 
 tasks.named('javadoc').configure {