You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/07/01 04:29:27 UTC

(groovy-website) branch asf-site updated: better handling of deadlink checking

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

paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git


The following commit(s) were added to refs/heads/asf-site by this push:
     new de9b702  better handling of deadlink checking
de9b702 is described below

commit de9b7023753900fa784ed22b0e050207073f0929
Author: Paul King <pa...@asert.com.au>
AuthorDate: Wed Jul 1 14:29:18 2026 +1000

    better handling of deadlink checking
---
 buildSrc/src/main/groovy/util/CheckLinks.groovy | 86 ++++++++++++++++++++-----
 site-dev/build.gradle                           |  7 ++
 site-user/build.gradle                          |  9 ++-
 3 files changed, 86 insertions(+), 16 deletions(-)

diff --git a/buildSrc/src/main/groovy/util/CheckLinks.groovy b/buildSrc/src/main/groovy/util/CheckLinks.groovy
index 3b7a9c8..22d6108 100644
--- a/buildSrc/src/main/groovy/util/CheckLinks.groovy
+++ b/buildSrc/src/main/groovy/util/CheckLinks.groovy
@@ -33,6 +33,12 @@ class CheckLinks {
     final Map<File, List> deadLinks = [:]
     Map<Object, Object> isDead = [:]
     def baseDir
+    // Extra site roots to resolve local links against. The dev and user sites are
+    // generated separately but deployed together under the same domain, so a link
+    // from one site into the other only resolves once both are merged on the server.
+    // Listing the sibling site's build output here lets those cross-site links be
+    // validated instead of reported as false positives.
+    List additionalRoots = []
 
     boolean checkIsDead(String link, currentPath) {
         if (excludeFromChecks.any { link.startsWith(it) || (!it.startsWith('http') && link.endsWith(it)) }) {
@@ -41,21 +47,71 @@ class CheckLinks {
             return false
         }
 
+        def lower = link.toLowerCase()
+
+        // Same-page anchors and non-navigational schemes are not checkable links.
+        // Values containing whitespace are not real href/src targets either - they are
+        // usually fragments of inline JavaScript (e.g. "' + r.url + '") caught by the
+        // attribute regex - so skip them rather than treat them as dead links.
+        if (!link || link.startsWith('#') || link =~ /\s/ || lower ==~ /^(mailto|tel|javascript|data):.*/) {
+            return false
+        }
+
+        // Treat protocol-relative links (//host/path) as https for remote checking.
+        if (link.startsWith('//')) {
+            return checkRemote('https:' + link)
+        }
+
+        if (lower.startsWith('http://') || lower.startsWith('https://')) {
+            return checkRemote(link)
+        }
+
+        // Anything else is a link into the generated site: verify it exists on disk.
+        // We resolve against the built site using File (never by hand-building a
+        // "file://" string) so paths containing spaces or other characters that are
+        // illegal in a URI - e.g. a CI workspace named "Groovy dev website" - do not
+        // spuriously report every local link as dead.
+        return !localTargetExists(link, currentPath)
+    }
+
+    private boolean localTargetExists(String link, currentPath) {
+        // Drop any fragment (#...) or query (?...) before resolving to a file.
+        def path = link.replaceFirst(/[#?].*$/, '')
+        if (!path) {
+            return true
+        }
         try {
-            URL url
-            boolean rejected = false
-            try {
-                url = URI.create(link).toURL()
-            } catch (e) {
-                if (e.message.contains('URI is not absolute')) {
-                    rejected = true
-                }
-            }
-            if (rejected || !url) {
-                def path = "file:///${new File("$baseDir/${currentPath ? currentPath + '/' : ''}$link").canonicalPath.replace('\\', '/')}"
-                url = URI.create(path).toURL()
-                link = url.file
-            }
+            path = URLDecoder.decode(path, 'UTF-8')
+        } catch (ignored) {
+            // leave the raw path if it is not valid percent-encoding
+        }
+        boolean rootAbsolute = path.startsWith('/')
+        String relPath = rootAbsolute ? path.substring(1) : path
+        def roots = [baseDir, *additionalRoots].findAll { it }.collect { it as File }
+        return roots.any { File root ->
+            File base = (rootAbsolute || !currentPath) ? root : new File(root, currentPath.toString())
+            targetResolves(base, relPath)
+        }
+    }
+
+    // The web server serves extensionless "clean" URLs (e.g. blog/foo -> blog/foo.html)
+    // and directory URLs (foo/ -> foo/index.html), so accept those forms as well.
+    private boolean targetResolves(File base, String relPath) {
+        resolves(base, relPath) ||
+                resolves(base, relPath + '.html') ||
+                resolves(base, relPath + '/index.html')
+    }
+
+    private boolean resolves(File base, String relPath) {
+        // Normalise ".." segments lexically before touching the filesystem: a link such
+        // as "../releasenotes/foo.html" from a wiki/ page must resolve against a sibling
+        // site root even when that root has no wiki/ directory to traverse back out of.
+        new File(base, relPath).toPath().normalize().toFile().exists()
+    }
+
+    private boolean checkRemote(String link) {
+        try {
+            URL url = URI.create(link).toURL()
             logger?.debug("Checking URL: $url")
             def cx = url.openConnection()
             if (cx instanceof HttpURLConnection) {
@@ -75,7 +131,7 @@ class CheckLinks {
                         return true
                     }
                 } finally {
-                    response.close()
+                    response?.close()
                 }
             }
         } catch (e) {
diff --git a/site-dev/build.gradle b/site-dev/build.gradle
index 8cd3e35..599bcd5 100644
--- a/site-dev/build.gradle
+++ b/site-dev/build.gradle
@@ -40,6 +40,8 @@ def excludeFromChecks = [
     'https://issues.apache.org/jira',
     'target.html',
     'foo.html',
+    'mystyle.css',
+    '-unreleased.html',   // changelog placeholders generated during a Groovy release
     'http://www.acme.com/cars',
     'http://localhost:8080/groovy/hello.groovy'
 ]
@@ -154,7 +156,12 @@ tasks.register('checkDeadLinks') {
 
     doLast {
         def baseDir = generateSite.outputDir.get().asFile
+        // The user site is deployed alongside this one, so resolve cross-site links
+        // against its build output too (when available) to avoid false positives.
+        def siblingSite = file('../site-user/build/site')
+        def additionalRoots = siblingSite.directory ? [siblingSite] : []
         def checkLinks = new CheckLinks(baseDir: baseDir,
+                additionalRoots: additionalRoots,
                 excludeFromChecks: excludeFromChecks,
                 logger: logger)
 
diff --git a/site-user/build.gradle b/site-user/build.gradle
index ffa1426..ff7b70e 100644
--- a/site-user/build.gradle
+++ b/site-user/build.gradle
@@ -31,6 +31,8 @@ ext.excludeFromChecks = [
         'https://issues.apache.org/jira',
         'target.html',
         'foo.html',
+        'mystyle.css',
+        '-unreleased.html',   // changelog placeholders generated during a Groovy release
         'http://www.acme.com/cars',
         'http://localhost:8080/groovy/hello.groovy'
 ]
@@ -85,7 +87,12 @@ tasks.register('checkDeadLinks') {
 
     doLast {
         def baseDir = generateSite.outputDir.get().asFile
-        def checkLinks = new CheckLinks(baseDir: baseDir, excludeFromChecks: excludeFromChecks, logger: logger)
+        // The dev site is deployed alongside this one, so resolve cross-site links
+        // against its build output too (when available) to avoid false positives.
+        def siblingSite = file('../site-dev/build/site')
+        def additionalRoots = siblingSite.directory ? [siblingSite] : []
+        def checkLinks = new CheckLinks(baseDir: baseDir, additionalRoots: additionalRoots,
+                excludeFromChecks: excludeFromChecks, logger: logger)
 
         baseDir.eachFileRecurse {
             if (it.name.endsWith('.html')) {