From 77f2d2ce52349a56f2b33b234b031976b3669617 Mon Sep 17 00:00:00 2001 From: carlos Date: Sat, 20 Jun 2026 05:37:24 -0500 Subject: [PATCH] fix(docs): eliminate broken links in the generated /help manual --- src/catdata/cql/exp/AqlHelp.java | 67 +++++++++++++++++++++++----- src/catdata/cql/exp/AqlHelpTest.java | 66 +++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 src/catdata/cql/exp/AqlHelpTest.java diff --git a/src/catdata/cql/exp/AqlHelp.java b/src/catdata/cql/exp/AqlHelp.java index d309f67..afe6cc7 100644 --- a/src/catdata/cql/exp/AqlHelp.java +++ b/src/catdata/cql/exp/AqlHelp.java @@ -368,9 +368,37 @@ public static void help(File dir, boolean run) { Util.writeFile(search, new File(dir, "search.php").getAbsolutePath()); */ Map> index = new THashMap<>(); + // Examples for which no per-example page is generated below. Other pages (e.g. option pages) + // must not link to these names, or those links would 404. + Set undocumentedExamples = Set.of("Stdlib", "TutorialTSP"); + + // Syntax pages are generated further below only for non-var expressions enumerated by + // TypingInversion, and never for comments. Precompute the exact set of page basenames + // (kind + keyword) that WILL be written, so the per-example keyword cross-links can be + // suppressed when no target page exists. Mirrors the filters of the syntax-page loop; keep + // the two in sync. This is the single source of truth for "does a syntax page exist". + Map>>> z = + new TypingInversion().covisit(Unit.unit, x -> new AqlTyping()); + Set generatedSyntaxPages = new THashSet<>(); + for (Kind k : Kind.class.getEnumConstants()) { + if (k.equals(Kind.COMMENT)) { + continue; + } + for (Pair> pair : z.get(k)) { + if (pair.second.isVar()) { + continue; + } + generatedSyntaxPages.add(pair.second.kind().toString() + pair.second.getKeyword()); + } + } + // Keywords whose cross-link we dropped because no syntax page exists for them (var + // expressions, or keywords absent from TypingInversion's enumeration). Reported after the + // example loop so the docs gap stays visible instead of being silently hidden. + Set suppressedKeywordLinks = new THashSet<>(); + StringBuffer examples = new StringBuffer("" + css + ""); for (Example ex : Util.alphabetical(Examples.getExamples(Language.CQL))) { - if (ex.getName().equals("Stdlib") || ex.getName().equals("TutorialTSP")) { + if (undocumentedExamples.contains(ex.getName())) { continue; } examples.append( @@ -389,16 +417,23 @@ public static void help(File dir, boolean run) { index.put(ex, set); other.append("Keywords:

"); for (Exp e : prog.exps.values()) { + // Comments have no syntax page (see the Kind.COMMENT skip in the syntax loop), and their + // "keyword" is free-form body text, so linking them would emit a broken href. + if (e.kind().equals(Kind.COMMENT)) { + continue; + } if (!set.contains(e.getSyntax())) { set.add(e.getSyntax()); - other.append( - "\t\t\t" - + e.getKeyword() - + "
\n"); + String page = e.kind().toString() + e.getKeyword(); + if (generatedSyntaxPages.contains(page)) { + other.append( + "\t\t\t" + e.getKeyword() + "
\n"); + } else { + // No syntax page is generated for this keyword, so render it as plain text rather + // than emit a dead link. + other.append("\t\t\t" + e.getKeyword() + "
\n"); + suppressedKeywordLinks.add(page); + } } for (String k : e.options().keySet()) { if (opSeen.contains(k)) { @@ -467,6 +502,14 @@ public static void help(File dir, boolean run) { } examples.append("\n"); + if (!suppressedKeywordLinks.isEmpty()) { + System.out.println( + "AqlHelp: suppressed " + + suppressedKeywordLinks.size() + + " keyword cross-link(s) with no syntax page (no doc page exists for these): " + + Util.sep(Util.alphabetical(new ArrayList<>(suppressedKeywordLinks)), ", ")); + } + StringBuffer logo = new StringBuffer(""); logo.append("" + css + ""); logo.append("\nSyntax
"); @@ -500,8 +543,6 @@ public static void help(File dir, boolean run) { StringBuffer syntax = new StringBuffer(); syntax.append("" + css + "\n\t"); - Map>>> z = - new TypingInversion().covisit(Unit.unit, x -> new AqlTyping()); Map>> opInv = Util.newSetsFor(AqlOptions.optionNames()); boolean isFirstK = true; @@ -634,7 +675,9 @@ public static void help(File dir, boolean run) { } StringBuffer yyy = new StringBuffer(); for (Example example : Util.alphabetical(Examples.getExamples(Language.CQL))) { - + if (undocumentedExamples.contains(example.getName())) { + continue; + } if (example.getText().contains(ex.toString())) { yyy.append( "\n generated = new ArrayList<>(); + for (File f : dir.toFile().listFiles()) { + generated.add(f.getName()); + } + + List broken = new ArrayList<>(); + for (File f : dir.toFile().listFiles()) { + if (!f.getName().endsWith(".html")) { + continue; + } + String html = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + Matcher m = LINK.matcher(html); + while (m.find()) { + String target = m.group(1); + if (target.isEmpty() + || target.startsWith("http") + || target.startsWith("..") + || target.startsWith("#")) { + continue; + } + String base = target.split("#")[0].split("\\?")[0]; + if (!generated.contains(base)) { + broken.add(f.getName() + " -> " + target); + } + } + } + + assertTrue(broken.isEmpty(), "broken local links in generated manual: " + broken); + } +}