|
| 1 | +///usr/bin/env jbang "$0" "$@" ; exit $? |
| 2 | +//JAVA 21+ |
| 3 | +//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3 |
| 4 | + |
| 5 | +import com.fasterxml.jackson.core.type.TypeReference; |
| 6 | +import com.fasterxml.jackson.databind.JsonNode; |
| 7 | +import com.fasterxml.jackson.databind.ObjectMapper; |
| 8 | +import com.fasterxml.jackson.databind.SerializationFeature; |
| 9 | +import com.fasterxml.jackson.databind.node.ObjectNode; |
| 10 | + |
| 11 | +import java.io.IOException; |
| 12 | +import java.net.URLEncoder; |
| 13 | +import java.nio.charset.StandardCharsets; |
| 14 | +import java.nio.file.DirectoryStream; |
| 15 | +import java.nio.file.Files; |
| 16 | +import java.nio.file.Path; |
| 17 | +import java.util.ArrayList; |
| 18 | +import java.util.HashMap; |
| 19 | +import java.util.LinkedHashMap; |
| 20 | +import java.util.List; |
| 21 | +import java.util.Map; |
| 22 | +import java.util.Set; |
| 23 | +import java.util.regex.Matcher; |
| 24 | +import java.util.regex.Pattern; |
| 25 | + |
| 26 | +/** |
| 27 | + * Generate HTML detail pages from JSON snippet files and slug-template.html. |
| 28 | + * JBang equivalent of generate.py — produces identical output. |
| 29 | + */ |
| 30 | +public class Generate { |
| 31 | + |
| 32 | + static final String BASE_URL = "https://javaevolved.github.io"; |
| 33 | + static final String TEMPLATE_FILE = "slug-template.html"; |
| 34 | + |
| 35 | + static final Map<String, String> CATEGORY_DISPLAY = Map.ofEntries( |
| 36 | + Map.entry("language", "Language"), |
| 37 | + Map.entry("collections", "Collections"), |
| 38 | + Map.entry("strings", "Strings"), |
| 39 | + Map.entry("streams", "Streams"), |
| 40 | + Map.entry("concurrency", "Concurrency"), |
| 41 | + Map.entry("io", "I/O"), |
| 42 | + Map.entry("errors", "Errors"), |
| 43 | + Map.entry("datetime", "Date/Time"), |
| 44 | + Map.entry("security", "Security"), |
| 45 | + Map.entry("tooling", "Tooling") |
| 46 | + ); |
| 47 | + |
| 48 | + static final List<String> CATEGORIES = List.of( |
| 49 | + "language", "collections", "strings", "streams", "concurrency", |
| 50 | + "io", "errors", "datetime", "security", "tooling" |
| 51 | + ); |
| 52 | + |
| 53 | + static final Set<String> EXCLUDED_KEYS = Set.of("_path", "prev", "next", "related"); |
| 54 | + |
| 55 | + static final ObjectMapper mapper = new ObjectMapper(); |
| 56 | + static final Pattern TOKEN_PATTERN = Pattern.compile("\\{\\{(\\w+)}}"); |
| 57 | + |
| 58 | + public static void main(String[] args) throws IOException { |
| 59 | + String template = Files.readString(Path.of(TEMPLATE_FILE)); |
| 60 | + Map<String, JsonNode> allSnippets = loadAllSnippets(); |
| 61 | + System.out.println("Loaded " + allSnippets.size() + " snippets"); |
| 62 | + |
| 63 | + // Generate HTML files |
| 64 | + for (var entry : allSnippets.entrySet()) { |
| 65 | + JsonNode data = entry.getValue(); |
| 66 | + String htmlContent = generateHtml(template, data, allSnippets).strip(); |
| 67 | + String category = data.get("category").asText(); |
| 68 | + String slug = data.get("slug").asText(); |
| 69 | + Path outPath = Path.of(category, slug + ".html"); |
| 70 | + Files.writeString(outPath, htmlContent); |
| 71 | + } |
| 72 | + System.out.println("Generated " + allSnippets.size() + " HTML files"); |
| 73 | + |
| 74 | + // Rebuild data/snippets.json |
| 75 | + List<Map<String, Object>> snippetsList = new ArrayList<>(); |
| 76 | + for (var entry : allSnippets.entrySet()) { |
| 77 | + Map<String, Object> map = mapper.convertValue(entry.getValue(), |
| 78 | + new TypeReference<LinkedHashMap<String, Object>>() {}); |
| 79 | + EXCLUDED_KEYS.forEach(map::remove); |
| 80 | + snippetsList.add(map); |
| 81 | + } |
| 82 | + |
| 83 | + Files.createDirectories(Path.of("data")); |
| 84 | + ObjectMapper prettyMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); |
| 85 | + String json = prettyMapper.writeValueAsString(snippetsList) + "\n"; |
| 86 | + Files.writeString(Path.of("data", "snippets.json"), json); |
| 87 | + System.out.println("Rebuilt data/snippets.json with " + snippetsList.size() + " entries"); |
| 88 | + |
| 89 | + // Patch index.html with the current snippet count |
| 90 | + int count = allSnippets.size(); |
| 91 | + String indexContent = Files.readString(Path.of("index.html")); |
| 92 | + indexContent = indexContent.replace("{{snippetCount}}", String.valueOf(count)); |
| 93 | + Files.writeString(Path.of("index.html"), indexContent); |
| 94 | + System.out.println("Patched index.html with snippet count: " + count); |
| 95 | + } |
| 96 | + |
| 97 | + static Map<String, JsonNode> loadAllSnippets() throws IOException { |
| 98 | + Map<String, JsonNode> snippets = new LinkedHashMap<>(); |
| 99 | + for (String cat : CATEGORIES) { |
| 100 | + Path catDir = Path.of(cat); |
| 101 | + if (!Files.isDirectory(catDir)) continue; |
| 102 | + List<Path> jsonFiles = new ArrayList<>(); |
| 103 | + try (DirectoryStream<Path> stream = Files.newDirectoryStream(catDir, "*.json")) { |
| 104 | + stream.forEach(jsonFiles::add); |
| 105 | + } |
| 106 | + jsonFiles.sort(Path::compareTo); |
| 107 | + for (Path path : jsonFiles) { |
| 108 | + JsonNode data = mapper.readTree(path.toFile()); |
| 109 | + String key = data.get("category").asText() + "/" + data.get("slug").asText(); |
| 110 | + snippets.put(key, data); |
| 111 | + } |
| 112 | + } |
| 113 | + return snippets; |
| 114 | + } |
| 115 | + |
| 116 | + static String escape(String text) { |
| 117 | + if (text == null) return ""; |
| 118 | + return text.replace("&", "&") |
| 119 | + .replace("<", "<") |
| 120 | + .replace(">", ">") |
| 121 | + .replace("\"", """) |
| 122 | + .replace("'", "'"); |
| 123 | + } |
| 124 | + |
| 125 | + static String jsonEscape(String text) { |
| 126 | + // Produce ASCII-only JSON string content (matching Python json.dumps(ensure_ascii=True)[1:-1]) |
| 127 | + try { |
| 128 | + String full = mapper.writeValueAsString(text); // includes surrounding quotes |
| 129 | + String inner = full.substring(1, full.length() - 1); |
| 130 | + // Jackson doesn't escape non-ASCII by default; do it manually |
| 131 | + StringBuilder sb = new StringBuilder(); |
| 132 | + for (int i = 0; i < inner.length(); i++) { |
| 133 | + char c = inner.charAt(i); |
| 134 | + if (c > 127) { |
| 135 | + sb.append(String.format("\\u%04x", (int) c)); |
| 136 | + } else { |
| 137 | + sb.append(c); |
| 138 | + } |
| 139 | + } |
| 140 | + return sb.toString(); |
| 141 | + } catch (IOException e) { |
| 142 | + throw new RuntimeException(e); |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + static String urlEncode(String s) { |
| 147 | + return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20"); |
| 148 | + } |
| 149 | + |
| 150 | + static String renderNavArrows(JsonNode data) { |
| 151 | + List<String> parts = new ArrayList<>(); |
| 152 | + if (data.has("prev") && !data.get("prev").isNull()) { |
| 153 | + parts.add("<a href=\"/" + data.get("prev").asText() + ".html\" aria-label=\"Previous pattern\">←</a>"); |
| 154 | + } else { |
| 155 | + parts.add("<span class=\"nav-arrow-disabled\">←</span>"); |
| 156 | + } |
| 157 | + if (data.has("next") && !data.get("next").isNull()) { |
| 158 | + parts.add("<a href=\"/" + data.get("next").asText() + ".html\" aria-label=\"Next pattern\">→</a>"); |
| 159 | + } else { |
| 160 | + parts.add(""); |
| 161 | + } |
| 162 | + return String.join("\n ", parts); |
| 163 | + } |
| 164 | + |
| 165 | + static String renderWhyCards(JsonNode whyList) { |
| 166 | + List<String> cards = new ArrayList<>(); |
| 167 | + for (JsonNode w : whyList) { |
| 168 | + cards.add(" <div class=\"why-card\">\n" |
| 169 | + + " <div class=\"why-icon\">" + w.get("icon").asText() + "</div>\n" |
| 170 | + + " <h3>" + escape(w.get("title").asText()) + "</h3>\n" |
| 171 | + + " <p>" + escape(w.get("desc").asText()) + "</p>\n" |
| 172 | + + " </div>"); |
| 173 | + } |
| 174 | + return String.join("\n", cards); |
| 175 | + } |
| 176 | + |
| 177 | + static String renderRelatedCard(JsonNode relatedData) { |
| 178 | + String cat = relatedData.get("category").asText(); |
| 179 | + String slug = relatedData.get("slug").asText(); |
| 180 | + String catDisplay = CATEGORY_DISPLAY.get(cat); |
| 181 | + String path = cat + "/" + slug; |
| 182 | + String difficulty = relatedData.get("difficulty").asText(); |
| 183 | + |
| 184 | + return " <a href=\"/" + path + ".html\" class=\"tip-card\">\n" |
| 185 | + + " <div class=\"tip-card-body\">\n" |
| 186 | + + " <div class=\"tip-card-header\">\n" |
| 187 | + + " <div class=\"tip-badges\">\n" |
| 188 | + + " <span class=\"badge " + cat + "\">" + catDisplay + "</span>\n" |
| 189 | + + " <span class=\"badge " + difficulty + "\">" + difficulty + "</span>\n" |
| 190 | + + " </div>\n" |
| 191 | + + " </div>\n" |
| 192 | + + " <h3>" + escape(relatedData.get("title").asText()) + "</h3>\n" |
| 193 | + + " </div>\n" |
| 194 | + + " <div class=\"card-code\">\n" |
| 195 | + + " <div class=\"card-code-layer old-layer\">\n" |
| 196 | + + " <div class=\"mini-label\">" + escape(relatedData.get("oldLabel").asText()) + "</div>\n" |
| 197 | + + " <pre class=\"code-text\">" + escape(relatedData.get("oldCode").asText()) + "</pre>\n" |
| 198 | + + " </div>\n" |
| 199 | + + " <div class=\"card-code-layer modern-layer\">\n" |
| 200 | + + " <div class=\"mini-label\">" + escape(relatedData.get("modernLabel").asText()) + "</div>\n" |
| 201 | + + " <pre class=\"code-text\">" + escape(relatedData.get("modernCode").asText()) + "</pre>\n" |
| 202 | + + " </div>\n" |
| 203 | + + " <span class=\"hover-hint\">Hover to see modern ➜</span>\n" |
| 204 | + + " </div>\n" |
| 205 | + + " <div class=\"tip-card-footer\">\n" |
| 206 | + + " <span class=\"browser-support\"><span class=\"dot\"></span>JDK " + relatedData.get("jdkVersion").asText() + "+</span>\n" |
| 207 | + + " <span class=\"arrow-link\">→</span>\n" |
| 208 | + + " </div>\n" |
| 209 | + + " </a>"; |
| 210 | + } |
| 211 | + |
| 212 | + static String renderRelatedSection(JsonNode relatedPaths, Map<String, JsonNode> allSnippets) { |
| 213 | + List<String> cards = new ArrayList<>(); |
| 214 | + if (relatedPaths != null) { |
| 215 | + for (JsonNode pathNode : relatedPaths) { |
| 216 | + String path = pathNode.asText(); |
| 217 | + if (allSnippets.containsKey(path)) { |
| 218 | + cards.add(renderRelatedCard(allSnippets.get(path))); |
| 219 | + } |
| 220 | + } |
| 221 | + } |
| 222 | + return String.join("\n", cards); |
| 223 | + } |
| 224 | + |
| 225 | + static String renderSocialShare(String slug, String title) { |
| 226 | + String pageUrl = BASE_URL + "/" + slug + ".html"; |
| 227 | + String shareText = title + " \u2013 java.evolved"; |
| 228 | + String encodedUrl = urlEncode(pageUrl); |
| 229 | + String encodedText = urlEncode(shareText); |
| 230 | + |
| 231 | + String xUrl = "https://x.com/intent/tweet?url=" + encodedUrl + "&text=" + encodedText; |
| 232 | + String bskyUrl = "https://bsky.app/intent/compose?text=" + encodedText + "%20" + encodedUrl; |
| 233 | + String liUrl = "https://www.linkedin.com/sharing/share-offsite/?url=" + encodedUrl; |
| 234 | + String redditUrl = "https://www.reddit.com/submit?url=" + encodedUrl + "&title=" + encodedText; |
| 235 | + |
| 236 | + return " <div class=\"social-share\">\n" |
| 237 | + + " <span class=\"share-label\">Share</span>\n" |
| 238 | + + " <a href=\"" + xUrl + "\" target=\"_blank\" rel=\"noopener\" class=\"share-btn share-x\" aria-label=\"Share on X\">𝕏</a>\n" |
| 239 | + + " <a href=\"" + bskyUrl + "\" target=\"_blank\" rel=\"noopener\" class=\"share-btn share-bsky\" aria-label=\"Share on Bluesky\">🦋</a>\n" |
| 240 | + + " <a href=\"" + liUrl + "\" target=\"_blank\" rel=\"noopener\" class=\"share-btn share-li\" aria-label=\"Share on LinkedIn\">in</a>\n" |
| 241 | + + " <a href=\"" + redditUrl + "\" target=\"_blank\" rel=\"noopener\" class=\"share-btn share-reddit\" aria-label=\"Share on Reddit\">⬡</a>\n" |
| 242 | + + " </div>"; |
| 243 | + } |
| 244 | + |
| 245 | + static String generateHtml(String template, JsonNode data, Map<String, JsonNode> allSnippets) { |
| 246 | + String cat = data.get("category").asText(); |
| 247 | + String slug = data.get("slug").asText(); |
| 248 | + String catDisplay = CATEGORY_DISPLAY.get(cat); |
| 249 | + String title = data.get("title").asText(); |
| 250 | + |
| 251 | + Map<String, String> replacements = new HashMap<>(); |
| 252 | + replacements.put("title", escape(title)); |
| 253 | + replacements.put("summary", escape(data.get("summary").asText())); |
| 254 | + replacements.put("slug", slug); |
| 255 | + replacements.put("category", cat); |
| 256 | + replacements.put("categoryDisplay", catDisplay); |
| 257 | + replacements.put("difficulty", data.get("difficulty").asText()); |
| 258 | + replacements.put("jdkVersion", data.get("jdkVersion").asText()); |
| 259 | + replacements.put("oldLabel", escape(data.get("oldLabel").asText())); |
| 260 | + replacements.put("modernLabel", escape(data.get("modernLabel").asText())); |
| 261 | + replacements.put("oldCode", escape(data.get("oldCode").asText())); |
| 262 | + replacements.put("modernCode", escape(data.get("modernCode").asText())); |
| 263 | + replacements.put("oldApproach", escape(data.get("oldApproach").asText())); |
| 264 | + replacements.put("modernApproach", escape(data.get("modernApproach").asText())); |
| 265 | + replacements.put("explanation", escape(data.get("explanation").asText())); |
| 266 | + replacements.put("support", escape(data.get("support").asText())); |
| 267 | + replacements.put("canonicalUrl", BASE_URL + "/" + cat + "/" + slug + ".html"); |
| 268 | + replacements.put("flatUrl", BASE_URL + "/" + slug + ".html"); |
| 269 | + replacements.put("titleJson", jsonEscape(title)); |
| 270 | + replacements.put("summaryJson", jsonEscape(data.get("summary").asText())); |
| 271 | + replacements.put("categoryDisplayJson", jsonEscape(catDisplay)); |
| 272 | + replacements.put("navArrows", renderNavArrows(data)); |
| 273 | + replacements.put("whyCards", renderWhyCards(data.get("whyModernWins"))); |
| 274 | + replacements.put("relatedCards", renderRelatedSection(data.get("related"), allSnippets)); |
| 275 | + replacements.put("socialShare", renderSocialShare(slug, title)); |
| 276 | + |
| 277 | + Matcher m = TOKEN_PATTERN.matcher(template); |
| 278 | + StringBuilder sb = new StringBuilder(); |
| 279 | + while (m.find()) { |
| 280 | + String key = m.group(1); |
| 281 | + String replacement = replacements.getOrDefault(key, m.group(0)); |
| 282 | + m.appendReplacement(sb, Matcher.quoteReplacement(replacement)); |
| 283 | + } |
| 284 | + m.appendTail(sb); |
| 285 | + return sb.toString(); |
| 286 | + } |
| 287 | +} |
0 commit comments