Skip to content

Commit 82fdff3

Browse files
brunoborgesCopilot
andcommitted
Add OG social preview SVG card generator
JBang script that generates 1200×630 SVG cards for Open Graph social previews. Each card shows the pattern title, category badge, and side-by-side Old vs Modern code panels with Java syntax highlighting. Features: - Dynamic font sizing (9-16px) per pattern to fill panel area - Syntax highlighting: keywords, types, strings, comments, annotations, numbers - Light theme colors matching the site design - xml:space=preserve for proper code indentation - Truncation with '...' for code exceeding panel height Usage: jbang html-generators/generateog.java # all patterns jbang html-generators/generateog.java category/slug # single pattern Output: site/og/{category}/{slug}.svg (gitignored as generated artifacts) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3d4e3f4 commit 82fdff3

File tree

2 files changed

+359
-0
lines changed

2 files changed

+359
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,6 @@ html-generators/generate.aot
2626
html-generators/generate.jar
2727
html-generators/generate.jsa
2828
html-generators/generate.classlist
29+
30+
# Generated OG social preview SVGs (built by html-generators/generateog.java)
31+
site/og/

html-generators/generateog.java

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
107+
.replace("\"", "&quot;").replace("'", "&apos;");
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&amp;family=JetBrains+Mono:wght@400;500&amp;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

Comments
 (0)