blob: aed66a77082db506d292082647e2b34f7f96eea3 [file] [log] [blame]
/*
* 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
*
* http://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.
*/
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.parser.Parser;
import org.jsoup.parser.Tag;
import org.jsoup.select.Elements;
import org.jsoup.select.NodeVisitor;
/**
* Check various things regarding anchors, links & general doc structure in the generated HTML site.
*
* <p>
* Usage: <code>java CheckLinksAndAnchors some-html-dir-name/ [-check-all-relative-links] [-bare-bones]</code>
* </p>
* <p>
* Problems this tool checks for...
* </p>
*
* <ul>
* <li>
* Validates that no file contains the same anchor more then once.
* </li>
* <li>
* Validates that relative links point to a file that actually exists, and if it's part of the ref-guide that any '#fragment' in the link refers to an ID that exists in that file.
* </li>
* <li>
* Our use of "<a href="https://getbootstrap.com/">Bootstrap</a>" features leverage some custom javascript
* for manipulating the DOM to keep the markup needed in the source <code>*.adoc</code> files simple, but it's
* still possible users may create asciidctor "blocks" that break conventions (either in Bootstrap or in our
* custom javascript)
* </li>
* </ul>
*
* <p>
* This tool parses the generated HTML site, looking for these situations in order to fail the build, since
* (depending on the type of check) these situations will result in inconsistent/broken HTML.
* </p>
* <p>
* This tool supports 2 command line options:
* </p>
* <ul>
* <li><b>-check-all-relative-links</b><br />
* <p>By default, only relative links to files in the same directory (ie: not startin with
* <code>"../"</code> are checked for existence. This means that we can do a "quick" validatation of
* links to other ref-guide files, but ignore relative links to things outside of the ref-guide --
* such as javadocs that we may not currently have built. If this option is specified then we
* <em>also</em> check relative links where the path starts with <code>"../"</code>
* </p>
* </li>
* <li><b>-bare-bones</b><br/>
* <p>By default, this tool assumes it is analyzing Jekyll generated files. If this option is specified,
* then it instead assumes it's checking "bare bones" HTML files...
* </p>
* <ul>
* <li>Jekyll Mode:
* <ul>
* <li>Requires all html pages have a "content" div; ignores all DOM Nodes that are
* <em>not</em> decendents of this div (to exclude redundent template based header, footer,
* &amp; sidebar links)
* </li>
* <li>Expects that the <code>&lt;body/&gt;</code> tag will have an <code>id</code> matching
* the page shortname.</li>
* </ul>
* </li>
* <li>Bare Bones Mode:
* <ul>
* <li>Checks all links &amp; anchors in the page.</li>
* <li>"Fakes" the existence of a <code>&lt;body id="..."&gt;</code> tag containing the
* page shortname.</li>
* </ul>
* </li>
* </ul>
* </li>
* </ul>
*
* TODO: build a list of all known external links so that some other tool could (optionally) ping them all for 200 status?
*
* @see https://github.com/asciidoctor/asciidoctor/issues/1865
* @see https://github.com/asciidoctor/asciidoctor/issues/1866
*/
public class CheckLinksAndAnchors { // TODO: rename this class now that it does more then just links & anchors
public static final class HtmlFileFilter implements FileFilter {
public boolean accept(File pathname) {
return pathname.getName().toLowerCase().endsWith("html");
}
}
public static void main(String[] args) throws Exception {
int problems = 0;
if (args.length < 1) {
System.err.println("usage: CheckLinksAndAnchors <htmldir> [-check-all-relative-links] [-bare-bones]");
System.exit(-1);
}
final File htmlDir = new File(args[0]);
final Set<String> options = new LinkedHashSet<>();
for (int i = 1; i < args.length; i++) {
if (! args[i].trim().isEmpty()) { // ignore blank options - maybe an ant sysprop blanked on purpose
options.add(args[i]);
}
}
final boolean bareBones = options.remove("-bare-bones");
final boolean checkAllRelativeLinks = options.remove("-check-all-relative-links");
if (! options.isEmpty()) {
for (String brokenOpt : options) {
System.err.println("CheckLinksAndAnchors: Unrecognized option: " + brokenOpt);
}
System.exit(-1);
}
final File[] pages = htmlDir.listFiles(new HtmlFileFilter());
if (0 == pages.length) {
System.err.println("CheckLinksAndAnchors: No HTML Files found, wrong htmlDir? forgot to built the site?");
System.exit(-1);
}
final Map<File,List<URI>> filesToRelativeLinks = new HashMap<>();
final Map<String,Set<String>> filesToIds = new HashMap<>();
int totalLinks = 0;
int totalRelativeLinks = 0;
int totalIds = 0;
for (File file : pages) {
//System.out.println("input File URI: " + file.toURI().toString());
assert ! filesToRelativeLinks.containsKey(file);
final List<URI> linksInThisFile = new ArrayList<URI>(17);
filesToRelativeLinks.put(file, linksInThisFile);
final Set<String> idsInThisFile = new LinkedHashSet<String>(17);
filesToIds.put(file.getName(), idsInThisFile);
// use this for error reporting if an ID exists multiple times in a single document
final Map<String,List<Element>> idsToNodes = new HashMap<>();
final String fileContents = readFile(file.getPath());
final Document doc = Jsoup.parse(fileContents);
// For Jekyll, we only care about class='content' -- we don't want to worry
// about ids/links duplicated in the header/footer of every page,
final String mainContentSelector = bareBones ? "body" : ".link-check-root";
final Elements mainContents = doc.select(mainContentSelector);
if (1 != mainContents.size()) {
throw new RuntimeException(file.getName() + " has " + mainContents.size() + " main content elements: " + mainContentSelector);
}
final Element mainContent = mainContents.first();
// All of the ID (nodes) in (the content of) this doc
final Elements nodesWithIds = mainContent.select("[id]");
if (bareBones) {
// It's a pain in the ass to customize the HTML output structure asciidoctor's bare-bones html5 backend
// so instead we "fake" that the body tag contains the attribute we use in jekyll
nodesWithIds.add(new Element(Tag.valueOf("body"), "").attr("id", file.getName().replaceAll("\\.html$","")));
} else {
// We have to add Jekyll's <body> to the nodesWithIds so we check the main section anchor as well
// since we've already drilled down below it
nodesWithIds.addAll(doc.select("body[id]"));
}
boolean foundPreamble = false;
for (Element node : nodesWithIds) {
final String id = node.id();
assert null != id;
assert 0 != id.length();
// special case id: we ignore the first 'preamble' because
// it's part of the core markup that asciidoctor always uses
// if we find it a second time in a single page, fail with a special error...
if (id.equals("preamble")) {
if (foundPreamble) {
problems++;
System.err.println(file.toURI().toString() +
" contains 'preamble' anchor, this is special in jekyll and must not be used in content.");
} else {
foundPreamble = true;
continue; // Note: we specifically don't count this in totalIds
}
}
if (idsInThisFile.contains(id)) {
problems++;
System.err.println(file.toURI().toString() + " contains ID multiple times: " + id);
}
idsInThisFile.add(id);
totalIds++; // Note: we specifically don't count 'preamble'
}
// build up the list of (relative) linksInThisFile
final Elements links = mainContent.select("a[href]");
for (Element link : links) {
totalLinks++;
final String href = link.attr("href");
if (0 == href.length()) {
problems++;
System.err.println(file.toURI().toString() + " contains link with empty href");
}
try {
final URI uri = new URI(href);
if (! uri.isAbsolute()) {
totalRelativeLinks++;
// track the link to (later) validate the target doc exists and contains the linked anchor (if any)
linksInThisFile.add(uri);
}
} catch (URISyntaxException uri_ex) {
// before reporting a problem, see if it can be parsed as a valid (absolute) URL
// some solr examples URLs have characters that aren't legal URI characters
// Example: "ipod^3.0", "foo:[*+TO+*]", etc...
boolean href_is_valid_absolute_url = false;
try {
// if this isn't absolute, it will fail
final URL ignored = new URL(href);
href_is_valid_absolute_url = true;
} catch (MalformedURLException url_ex) {
problems++;
System.err.println(file.toURI().toString() + " contains link w/ invalid syntax: " + href);
System.err.println(" ... as URI: " + uri_ex.toString());
System.err.println(" ... as URL: " + url_ex.toString());
}
}
}
problems += validateHtmlStructure(file, mainContent);
}
// check every (relative) link in every file to ensure the target page exists, and contains the linked anchor (if any)
for (Map.Entry<File,List<URI>> entry : filesToRelativeLinks.entrySet()) {
final File source = entry.getKey();
for (URI link : entry.getValue()) {
final String path = (null == link.getPath() || "".equals(link.getPath())) ? source.getName() : link.getPath();
final File dest = new File(htmlDir, path);
if ( ! dest.exists() ) {
// this is only a problem if it's in our dir, or checkAllRelativeLinks is set...
if (checkAllRelativeLinks || ! path.startsWith("../")) {
problems++;
System.err.println("Relative link points at dest file that doesn't exist: " + link);
System.err.println(" ... source: " + source.toURI().toString());
}
} else {
if ( ! path.startsWith("../") ) {
// if the dest file is part of the ref guide (ie: not an "up and out" link to javadocs)
// then we validate the fragment (if any) is known and exists in that file...
final String frag = link.getFragment();
if ( ! (null == frag || frag.isEmpty()) ) {
final Set<String> knownIdsInDest = filesToIds.get(dest.getName());
assert null != knownIdsInDest : dest.getName();
if (! knownIdsInDest.contains(frag) ) {
problems++;
System.err.println("Relative link points at id that doesn't exist in dest: " + link);
System.err.println(" ... source: " + source.toURI().toString());
}
}
}
}
}
}
System.err.println("Processed " + totalLinks + " links (" + totalRelativeLinks + " relative) to " +
totalIds + " anchors in " + pages.length + " files");
if (0 < problems) {
System.err.println("Total of " + problems + " problems found");
System.exit(-1);
}
}
static String readFile(String fileName) throws IOException {
InputStream in = new FileInputStream(fileName);
Reader reader = new InputStreamReader(in,"UTF-8");
BufferedReader br = new BufferedReader(reader);
try {
StringBuilder sb = new StringBuilder();
String line = br.readLine();
while (line != null) {
sb.append(line);
sb.append("\n");
line = br.readLine();
}
return sb.toString();
} finally {
br.close();
}
}
/**
* returns the number of problems found with this file
*/
private static int validateHtmlStructure(final File f, final Element mainContent) {
final String file = f.toURI().toString();
int problems = 0;
for (Element tab : mainContent.select(".dynamic-tabs")) {
// must be at least two tab-pane decendents of each dynamic-tabs
final Elements panes = tab.select(".tab-pane");
final int numPanes = panes.size();
if (numPanes < 2) {
System.err.println(file + " contains a 'dynamic-tabs' with "+ numPanes+" 'tab-pane' decendents -- must be at least 2");
problems++;
}
// must not have any decendents of a dynamic-tabs that are not part of tab-pane
//
// this is kind of tricky, because asciidoctor creates wrapper divs around the tab-panes
// so we can't make assumptions about direct children
//
final Elements elementsToIgnore = panes.parents();
for (Element pane : panes) {
elementsToIgnore.addAll(pane.select("*"));
}
final Elements nonPaneDecendents = tab.select("*");
nonPaneDecendents.removeAll(elementsToIgnore);
if (0 != nonPaneDecendents.size()) {
System.err.println(file + " contains a 'dynamic-tabs' with content outside of a 'tab-pane': " +
shortStr(nonPaneDecendents.text()));
problems++;
}
}
// Now fetch all tab-panes, even if they aren't in a dynamic-tabs instance
// (that's a type of error we want to check for)
final Elements validPanes = mainContent.select(".dynamic-tabs .tab-pane");
final Elements allPanes = mainContent.select(".tab-pane");
for (Element pane : allPanes) {
// every tab-pane must have an id
if (pane.id().trim().isEmpty()) {
System.err.println(file + " contains a 'tab-pane' that does not have a (unique) '#id'");
problems++;
}
final String debug = "'tab-pane" + (pane.id().isEmpty() ? "" : "#" + pane.id()) + "'";
// no 'active' class on any tab-pane
if (pane.classNames().contains("active")) {
System.err.println(file + " contains " + debug + " with 'active' defined -- this must be removed");
problems++;
}
// every tab-pane must be a decendent of a dynamic-tabs
if (! validPanes.contains(pane)) {
System.err.println(file + " contains " + debug + " that is not a decendent of a 'dynamic-tabs'");
problems++;
}
// every tab-pane must have exactly 1 tab-label which is <strong>
Elements labels = pane.select(".tab-label");
if (1 != labels.size()) {
System.err.println(file + " contains " + debug + " with " + labels.size() + " 'tab-label' decendents -- must be exactly 1");
problems++;
} else {
Element label = labels.first();
if (! label.tagName().equals("strong")) {
System.err.println(file + " contains " + debug + " with a 'tab-label' using <"
+ labels.first().tagName() + "> -- each 'tab-label' must be <strong> (example: '[.tab-label]*Text*')");
problems++;
}
final String labelText = label.text().trim();
// if the tab-label is the empty string, asciidoctor should optimize it away -- but let's check for it anyway
if (labelText.isEmpty()) {
System.err.println(file + " contains " + debug + " with a blank 'tab-label'");
problems++;
}
// validate label must be first paragraph? first text content?
if (! pane.text().trim().startsWith(labelText)) {
System.err.println(file + " contains " + debug + " with text before the 'tab-label' ('" + labelText + "')");
problems++;
}
}
}
return problems;
}
public static final String shortStr(String s) {
if (s.length() < 20) {
return s;
}
return s.substring(0, 17) + "...";
}
}