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')) {