|
| 1 | +///usr/bin/env jbang "$0" "$@" ; exit $? |
| 2 | +//JAVA 25 |
| 3 | +//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3 |
| 4 | +//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3 |
| 5 | + |
| 6 | +import module java.base; |
| 7 | +import com.fasterxml.jackson.databind.*; |
| 8 | +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; |
| 9 | + |
| 10 | +/** |
| 11 | + * Generate Open Graph SVG cards (1200×630) for each pattern. |
| 12 | + * Light theme, side-by-side Old/Modern code, slug title at top. |
| 13 | + * |
| 14 | + * Usage: jbang html-generators/generate-og.java [category/slug] |
| 15 | + * No arguments → generate all patterns. |
| 16 | + */ |
| 17 | +static final String CONTENT_DIR = "content"; |
| 18 | +static final String OUTPUT_DIR = "site/og"; |
| 19 | +static final String CATEGORIES_FILE = "html-generators/categories.properties"; |
| 20 | + |
| 21 | +static final ObjectMapper JSON_MAPPER = new ObjectMapper(); |
| 22 | +static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); |
| 23 | +static final Map<String, ObjectMapper> MAPPERS = Map.of( |
| 24 | + "json", JSON_MAPPER, "yaml", YAML_MAPPER, "yml", YAML_MAPPER |
| 25 | +); |
| 26 | + |
| 27 | +static final SequencedMap<String, String> CATEGORY_DISPLAY = loadProperties(CATEGORIES_FILE); |
| 28 | + |
| 29 | +// ── Light-theme palette ──────────────────────────────────────────────── |
| 30 | +static final String BG = "#ffffff"; |
| 31 | +static final String BORDER = "#d8d8e0"; |
| 32 | +static final String TEXT = "#1a1a2e"; |
| 33 | +static final String TEXT_MUTED = "#6b7280"; |
| 34 | +static final String OLD_BG = "#fef2f2"; |
| 35 | +static final String MODERN_BG = "#eff6ff"; |
| 36 | +static final String OLD_ACCENT = "#dc2626"; |
| 37 | +static final String GREEN = "#059669"; |
| 38 | +static final String ACCENT = "#6366f1"; |
| 39 | +static final String BADGE_BG = "#f3f4f6"; |
| 40 | + |
| 41 | +// ── Syntax highlight colors (VS Code light-inspired) ─────────────────── |
| 42 | +static final String SYN_KEYWORD = "#7c3aed"; // purple — keywords & modifiers |
| 43 | +static final String SYN_TYPE = "#0e7490"; // teal — type names |
| 44 | +static final String SYN_STRING = "#059669"; // green — strings & chars |
| 45 | +static final String SYN_COMMENT = "#6b7280"; // gray — comments |
| 46 | +static final String SYN_ANNOTATION = "#b45309"; // amber — annotations |
| 47 | +static final String SYN_NUMBER = "#c2410c"; // orange — numeric literals |
| 48 | +static final String SYN_DEFAULT = "#1a1a2e"; // dark — everything else |
| 49 | + |
| 50 | +static final Set<String> JAVA_KEYWORDS = Set.of( |
| 51 | + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", |
| 52 | + "class", "const", "continue", "default", "do", "double", "else", "enum", |
| 53 | + "extends", "final", "finally", "float", "for", "goto", "if", "implements", |
| 54 | + "import", "instanceof", "int", "interface", "long", "native", "new", "null", |
| 55 | + "package", "private", "protected", "public", "record", "return", "sealed", |
| 56 | + "short", "static", "strictfp", "super", "switch", "synchronized", "this", |
| 57 | + "throw", "throws", "transient", "try", "var", "void", "volatile", "when", |
| 58 | + "while", "with", "yield", "permits", "non-sealed", "module", "open", "opens", |
| 59 | + "requires", "exports", "provides", "to", "uses", "transitive", |
| 60 | + "true", "false" |
| 61 | +); |
| 62 | + |
| 63 | +static final Pattern SYN_PATTERN = Pattern.compile( |
| 64 | + "(?<comment>//.*)|" + // line comment |
| 65 | + "(?<blockcomment>/\\*.*?\\*/)|" + // block comment (single line) |
| 66 | + "(?<annotation>@\\w+)|" + // annotation |
| 67 | + "(?<string>\"\"\"[\\s\\S]*?\"\"\"|\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*')|" + // strings |
| 68 | + "(?<number>\\b\\d[\\d_.]*[dDfFlL]?\\b)|" + // numbers |
| 69 | + "(?<word>\\b[A-Za-z_]\\w*\\b)|" + // words (keywords or identifiers) |
| 70 | + "(?<other>[^\\s])" // other single chars |
| 71 | +); |
| 72 | + |
| 73 | +// ── Dimensions ───────────────────────────────────────────────────────── |
| 74 | +static final int W = 1200, H = 630; |
| 75 | +static final int PAD = 40; |
| 76 | +static final int HEADER_H = 100; |
| 77 | +static final int FOOTER_H = 56; |
| 78 | +static final int CODE_TOP = HEADER_H; |
| 79 | +static final int CODE_H = H - HEADER_H - FOOTER_H; |
| 80 | +static final int COL_W = (W - PAD * 2 - 20) / 2; // 20px gap between panels |
| 81 | +static final int CODE_PAD = 14; // padding inside each panel |
| 82 | +static final int LABEL_H = 32; // space reserved for label above code |
| 83 | +static final int USABLE_W = COL_W - CODE_PAD * 2; // usable width for code text |
| 84 | +static final int USABLE_H = CODE_H - LABEL_H - CODE_PAD; // usable height for code text |
| 85 | +static final double CHAR_WIDTH_RATIO = 0.6; // monospace char width ≈ 0.6 × font size |
| 86 | +static final double LINE_HEIGHT_RATIO = 1.55; // line height ≈ 1.55 × font size |
| 87 | +static final int MIN_CODE_FONT = 9; |
| 88 | +static final int MAX_CODE_FONT = 16; |
| 89 | + |
| 90 | +// ── Helpers ──────────────────────────────────────────────────────────── |
| 91 | +static SequencedMap<String, String> loadProperties(String file) { |
| 92 | + try { |
| 93 | + var map = new LinkedHashMap<String, String>(); |
| 94 | + for (var line : Files.readAllLines(Path.of(file))) { |
| 95 | + line = line.strip(); |
| 96 | + if (line.isEmpty() || line.startsWith("#")) continue; |
| 97 | + var idx = line.indexOf('='); |
| 98 | + if (idx > 0) map.put(line.substring(0, idx).strip(), line.substring(idx + 1).strip()); |
| 99 | + } |
| 100 | + return map; |
| 101 | + } catch (IOException e) { throw new UncheckedIOException(e); } |
| 102 | +} |
| 103 | + |
| 104 | +static String xmlEscape(String s) { |
| 105 | + return s == null ? "" |
| 106 | + : s.replace("&", "&").replace("<", "<").replace(">", ">") |
| 107 | + .replace("\"", """).replace("'", "'"); |
| 108 | +} |
| 109 | + |
| 110 | +record Snippet(JsonNode node) { |
| 111 | + String get(String f) { return node.get(f).asText(); } |
| 112 | + String slug() { return get("slug"); } |
| 113 | + String category() { return get("category"); } |
| 114 | + String title() { return get("title"); } |
| 115 | + String jdkVersion() { return get("jdkVersion"); } |
| 116 | + String oldCode() { return get("oldCode"); } |
| 117 | + String modernCode() { return get("modernCode"); } |
| 118 | + String oldApproach() { return get("oldApproach"); } |
| 119 | + String modernApproach() { return get("modernApproach"); } |
| 120 | + String oldLabel() { return get("oldLabel"); } |
| 121 | + String modernLabel() { return get("modernLabel"); } |
| 122 | + String key() { return category() + "/" + slug(); } |
| 123 | + String catDisplay() { return CATEGORY_DISPLAY.get(category()); } |
| 124 | +} |
| 125 | + |
| 126 | +SequencedMap<String, Snippet> loadAllSnippets() throws IOException { |
| 127 | + var snippets = new LinkedHashMap<String, Snippet>(); |
| 128 | + for (var cat : CATEGORY_DISPLAY.sequencedKeySet()) { |
| 129 | + var catDir = Path.of(CONTENT_DIR, cat); |
| 130 | + if (!Files.isDirectory(catDir)) continue; |
| 131 | + var sorted = new ArrayList<Path>(); |
| 132 | + for (var ext : MAPPERS.keySet()) { |
| 133 | + try (var stream = Files.newDirectoryStream(catDir, "*." + ext)) { |
| 134 | + stream.forEach(sorted::add); |
| 135 | + } |
| 136 | + } |
| 137 | + sorted.sort(Path::compareTo); |
| 138 | + for (var path : sorted) { |
| 139 | + var ext = path.getFileName().toString(); |
| 140 | + ext = ext.substring(ext.lastIndexOf('.') + 1); |
| 141 | + var snippet = new Snippet(MAPPERS.get(ext).readTree(path.toFile())); |
| 142 | + snippets.put(snippet.key(), snippet); |
| 143 | + } |
| 144 | + } |
| 145 | + return snippets; |
| 146 | +} |
| 147 | + |
| 148 | +// ── SVG rendering ────────────────────────────────────────────────────── |
| 149 | + |
| 150 | +/** Compute the best font size (MIN–MAX) that fits both code blocks in their panels. */ |
| 151 | +static int bestFontSize(List<String> oldLines, List<String> modernLines) { |
| 152 | + int maxChars = Math.max( |
| 153 | + oldLines.stream().mapToInt(String::length).max().orElse(1), |
| 154 | + modernLines.stream().mapToInt(String::length).max().orElse(1) |
| 155 | + ); |
| 156 | + int maxLines = Math.max(oldLines.size(), modernLines.size()); |
| 157 | + |
| 158 | + // Largest font where the widest line fits the panel width |
| 159 | + int byWidth = (int) (USABLE_W / (maxChars * CHAR_WIDTH_RATIO)); |
| 160 | + // Largest font where all lines fit the panel height |
| 161 | + int byHeight = (int) (USABLE_H / (maxLines * LINE_HEIGHT_RATIO)); |
| 162 | + |
| 163 | + return Math.max(MIN_CODE_FONT, Math.min(MAX_CODE_FONT, Math.min(byWidth, byHeight))); |
| 164 | +} |
| 165 | + |
| 166 | +/** Truncate lines to fit the panel height at the given font size. */ |
| 167 | +static List<String> fitLines(List<String> lines, int fontSize) { |
| 168 | + int lineH = (int) (fontSize * LINE_HEIGHT_RATIO); |
| 169 | + int maxLines = USABLE_H / lineH; |
| 170 | + if (lines.size() <= maxLines) return lines; |
| 171 | + var truncated = new ArrayList<>(lines.subList(0, maxLines - 1)); |
| 172 | + truncated.add("..."); |
| 173 | + return truncated; |
| 174 | +} |
| 175 | + |
| 176 | +/** Syntax-highlight a single line of Java, returning SVG tspan fragments. */ |
| 177 | +static String highlightLine(String line) { |
| 178 | + if (line.equals("...")) return xmlEscape(line); |
| 179 | + var sb = new StringBuilder(); |
| 180 | + var m = SYN_PATTERN.matcher(line); |
| 181 | + int last = 0; |
| 182 | + while (m.find()) { |
| 183 | + // append any skipped whitespace |
| 184 | + if (m.start() > last) sb.append(xmlEscape(line.substring(last, m.start()))); |
| 185 | + last = m.end(); |
| 186 | + var token = m.group(); |
| 187 | + String color = null; |
| 188 | + if (m.group("comment") != null || m.group("blockcomment") != null) { |
| 189 | + color = SYN_COMMENT; |
| 190 | + } else if (m.group("annotation") != null) { |
| 191 | + color = SYN_ANNOTATION; |
| 192 | + } else if (m.group("string") != null) { |
| 193 | + color = SYN_STRING; |
| 194 | + } else if (m.group("number") != null) { |
| 195 | + color = SYN_NUMBER; |
| 196 | + } else if (m.group("word") != null) { |
| 197 | + if (JAVA_KEYWORDS.contains(token)) { |
| 198 | + color = SYN_KEYWORD; |
| 199 | + } else if (Character.isUpperCase(token.charAt(0))) { |
| 200 | + color = SYN_TYPE; |
| 201 | + } |
| 202 | + } |
| 203 | + if (color != null) { |
| 204 | + sb.append("<tspan fill=\"").append(color).append("\">").append(xmlEscape(token)).append("</tspan>"); |
| 205 | + } else { |
| 206 | + sb.append(xmlEscape(token)); |
| 207 | + } |
| 208 | + } |
| 209 | + if (last < line.length()) sb.append(xmlEscape(line.substring(last))); |
| 210 | + return sb.toString(); |
| 211 | +} |
| 212 | + |
| 213 | +/** Render a column of code lines as SVG <text> elements with syntax highlighting. */ |
| 214 | +static String renderCodeBlock(List<String> lines, int x, int y, int lineH) { |
| 215 | + var sb = new StringBuilder(); |
| 216 | + for (int i = 0; i < lines.size(); i++) { |
| 217 | + sb.append(" <text x=\"%d\" y=\"%d\" class=\"code\" xml:space=\"preserve\">%s</text>\n" |
| 218 | + .formatted(x, y + i * lineH, highlightLine(lines.get(i)))); |
| 219 | + } |
| 220 | + return sb.toString(); |
| 221 | +} |
| 222 | + |
| 223 | +static String generateSvg(Snippet s) { |
| 224 | + int leftX = PAD; |
| 225 | + int rightX = PAD + COL_W + 20; |
| 226 | + int labelY = CODE_TOP + 26; |
| 227 | + int codeY = CODE_TOP + 52; |
| 228 | + |
| 229 | + var rawOldLines = s.oldCode().lines().toList(); |
| 230 | + var rawModernLines = s.modernCode().lines().toList(); |
| 231 | + |
| 232 | + int fontSize = bestFontSize(rawOldLines, rawModernLines); |
| 233 | + int lineH = (int) (fontSize * LINE_HEIGHT_RATIO); |
| 234 | + |
| 235 | + var oldLines = fitLines(rawOldLines, fontSize); |
| 236 | + var modernLines = fitLines(rawModernLines, fontSize); |
| 237 | + |
| 238 | + return """ |
| 239 | +<?xml version="1.0" encoding="UTF-8"?> |
| 240 | +<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d"> |
| 241 | + <defs> |
| 242 | + <style> |
| 243 | + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); |
| 244 | + .title { font: 700 24px/1 'Inter', sans-serif; fill: %s; } |
| 245 | + .category { font: 600 13px/1 'Inter', sans-serif; fill: %s; } |
| 246 | + .label { font: 600 11px/1 'Inter', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; } |
| 247 | + .code { font: 400 %dpx/1 'JetBrains Mono', monospace; fill: %s; } |
| 248 | + .footer { font: 500 13px/1 'Inter', sans-serif; fill: %s; } |
| 249 | + .brand { font: 700 14px/1 'Inter', sans-serif; fill: %s; } |
| 250 | + </style> |
| 251 | + <clipPath id="clip-left"> |
| 252 | + <rect x="%d" y="%d" width="%d" height="%d" rx="8"/> |
| 253 | + </clipPath> |
| 254 | + <clipPath id="clip-right"> |
| 255 | + <rect x="%d" y="%d" width="%d" height="%d" rx="8"/> |
| 256 | + </clipPath> |
| 257 | + </defs> |
| 258 | +
|
| 259 | + <!-- Background --> |
| 260 | + <rect width="%d" height="%d" rx="16" fill="%s"/> |
| 261 | + <rect x="0.5" y="0.5" width="%d" height="%d" rx="16" fill="none" stroke="%s" stroke-width="1"/> |
| 262 | +
|
| 263 | + <!-- Header: category badge + title --> |
| 264 | + <rect x="%d" y="%d" width="%d" height="22" rx="6" fill="%s"/> |
| 265 | + <text x="%d" y="%d" class="category">%s</text> |
| 266 | + <text x="%d" y="%d" class="title">%s</text> |
| 267 | +
|
| 268 | + <!-- Left panel: Old code --> |
| 269 | + <rect x="%d" y="%d" width="%d" height="%d" rx="8" fill="%s"/> |
| 270 | + <rect x="%d" y="%d" width="%d" height="%d" rx="8" fill="none" stroke="%s" stroke-width="0.5"/> |
| 271 | + <text x="%d" y="%d" class="label" fill="%s">✗ %s</text> |
| 272 | + <g clip-path="url(#clip-left)"> |
| 273 | +%s </g> |
| 274 | +
|
| 275 | + <!-- Right panel: Modern code --> |
| 276 | + <rect x="%d" y="%d" width="%d" height="%d" rx="8" fill="%s"/> |
| 277 | + <rect x="%d" y="%d" width="%d" height="%d" rx="8" fill="none" stroke="%s" stroke-width="0.5"/> |
| 278 | + <text x="%d" y="%d" class="label" fill="%s">✓ %s</text> |
| 279 | + <g clip-path="url(#clip-right)"> |
| 280 | +%s </g> |
| 281 | +
|
| 282 | + <!-- Footer --> |
| 283 | + <text x="%d" y="%d" class="footer">JDK %s+</text> |
| 284 | + <text x="%d" y="%d" class="brand">javaevolved.github.io</text> |
| 285 | +</svg> |
| 286 | +""".formatted( |
| 287 | + // viewBox |
| 288 | + W, H, W, H, |
| 289 | + // style fills |
| 290 | + TEXT, TEXT_MUTED, fontSize, TEXT, TEXT_MUTED, ACCENT, |
| 291 | + // clip-left |
| 292 | + leftX, CODE_TOP, COL_W, CODE_H, |
| 293 | + // clip-right |
| 294 | + rightX, CODE_TOP, COL_W, CODE_H, |
| 295 | + // background |
| 296 | + W, H, BG, W - 1, H - 1, BORDER, |
| 297 | + // header badge |
| 298 | + PAD, 28, xmlEscape(s.catDisplay()).length() * 8 + 16, BADGE_BG, |
| 299 | + PAD + 8, 43, xmlEscape(s.catDisplay()), |
| 300 | + // title |
| 301 | + PAD, 76, xmlEscape(s.title()), |
| 302 | + // left panel bg + border |
| 303 | + leftX, CODE_TOP, COL_W, CODE_H, OLD_BG, |
| 304 | + leftX, CODE_TOP, COL_W, CODE_H, BORDER, |
| 305 | + // left label |
| 306 | + leftX + 14, labelY, OLD_ACCENT, xmlEscape(s.oldLabel()), |
| 307 | + // left code |
| 308 | + renderCodeBlock(oldLines, leftX + 14, codeY, lineH), |
| 309 | + // right panel bg + border |
| 310 | + rightX, CODE_TOP, COL_W, CODE_H, MODERN_BG, |
| 311 | + rightX, CODE_TOP, COL_W, CODE_H, BORDER, |
| 312 | + // right label |
| 313 | + rightX + 14, labelY, GREEN, xmlEscape(s.modernLabel()), |
| 314 | + // right code |
| 315 | + renderCodeBlock(modernLines, rightX + 14, codeY, lineH), |
| 316 | + // footer text |
| 317 | + PAD, H - 22, s.jdkVersion(), |
| 318 | + W - PAD, H - 22, |
| 319 | + // need text-anchor for brand — handled in the template |
| 320 | + "" // unused but keeps format args aligned |
| 321 | + ).replace( |
| 322 | + // Right-align the brand text |
| 323 | + "class=\"brand\">javaevolved.github.io</text>", |
| 324 | + "class=\"brand\" text-anchor=\"end\">javaevolved.github.io</text>" |
| 325 | + ); |
| 326 | +} |
| 327 | + |
| 328 | +// ── Main ─────────────────────────────────────────────────────────────── |
| 329 | +void main(String... args) throws IOException { |
| 330 | + var allSnippets = loadAllSnippets(); |
| 331 | + IO.println("Loaded %d snippets".formatted(allSnippets.size())); |
| 332 | + |
| 333 | + // Filter to a single slug if provided |
| 334 | + Collection<Snippet> targets; |
| 335 | + if (args.length > 0) { |
| 336 | + var key = args[0]; |
| 337 | + if (!allSnippets.containsKey(key)) { |
| 338 | + IO.println("Unknown pattern: " + key); |
| 339 | + IO.println("Available: " + String.join(", ", allSnippets.keySet())); |
| 340 | + System.exit(1); |
| 341 | + } |
| 342 | + targets = List.of(allSnippets.get(key)); |
| 343 | + } else { |
| 344 | + targets = allSnippets.values(); |
| 345 | + } |
| 346 | + |
| 347 | + int count = 0; |
| 348 | + for (var s : targets) { |
| 349 | + var dir = Path.of(OUTPUT_DIR, s.category()); |
| 350 | + Files.createDirectories(dir); |
| 351 | + var svg = generateSvg(s); |
| 352 | + Files.writeString(dir.resolve(s.slug() + ".svg"), svg); |
| 353 | + count++; |
| 354 | + } |
| 355 | + IO.println("Generated %d SVG card(s) in %s/".formatted(count, OUTPUT_DIR)); |
| 356 | +} |
0 commit comments