diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index ac20ee0b..00000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,272 +0,0 @@ -name: Benchmark Generators - -on: - workflow_dispatch: - -permissions: - contents: read - actions: write - -jobs: - benchmark: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-java@v6 - with: - distribution: 'temurin' - java-version: '25' - - - uses: jbangdev/setup-jbang@main - - - name: Setup Python - uses: actions/setup-python@v7 - with: - python-version: '3.13' - - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libcairo2-dev - - - name: Install Python dependencies - run: pip install pyyaml cairosvg - - - name: Run benchmark - shell: bash - run: | - JAR="html-generators/generate.jar" - AOT="html-generators/generate.aot" - OG_JAR="html-generators/generateog.jar" - OG_AOT="html-generators/generateog.aot" - STEADY_RUNS=5 - - snippet_count=$(find content \( -name '*.json' -o -name '*.yaml' -o -name '*.yml' \) -not -name 'template.*' | wc -l | tr -d ' ') - java_ver=$(java -version 2>&1 | head -1 | sed 's/.*"\(.*\)".*/\1/') - os_name="ubuntu-latest" - - # Nanosecond timestamp helper - _now() { python3 -c "import time; print(int(time.time()*1e9))"; } - - # Timing helper — returns seconds or "FAIL" - measure() { - local start end - start=$(_now) - if "$@" > /dev/null 2>&1; then - end=$(_now) - awk "BEGIN {printf \"%.2f\", ($end - $start) / 1000000000}" - else - echo "FAIL" - fi - } - - avg_runs() { - local n="$1"; shift - local sum=0 success=0 - for ((i = 1; i <= n; i++)); do - local t - t=$(measure "$@") - if [[ "$t" != "FAIL" ]]; then - sum=$(awk "BEGIN {print $sum + $t}") - success=$((success + 1)) - fi - done - if [[ $success -eq 0 ]]; then - echo "FAIL" - else - awk "BEGIN {printf \"%.2f\", $sum / $success}" - fi - } - - echo "Running benchmark on $os_name (Java $java_ver, $snippet_count snippets)..." - - # --- Phase 1: Training / build cost --- - rm -f "$JAR" "$AOT" "$OG_JAR" "$OG_AOT" - find html-generators -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true - - # HTML generator - PY_TRAIN=$(measure python3 html-generators/generate.py) - JBANG_EXPORT=$(measure jbang export fatjar --force --output "$JAR" html-generators/generate.java) - AOT_TRAIN=$(measure java -XX:AOTCacheOutput="$AOT" -jar "$JAR") - - # OG generator - OG_PY_TRAIN=$(measure python3 html-generators/generateog.py) - OG_JBANG_EXPORT=$(measure jbang export fatjar --force --output "$OG_JAR" html-generators/generateog.java) - OG_AOT_TRAIN=$(measure java -XX:AOTCacheOutput="$OG_AOT" -jar "$OG_JAR") - - # --- Phase 2: Steady-state execution --- - # HTML generator - PY_STEADY=$(avg_runs $STEADY_RUNS python3 html-generators/generate.py) - JBANG_STEADY=$(avg_runs $STEADY_RUNS jbang html-generators/generate.java) - JAR_STEADY=$(avg_runs $STEADY_RUNS java -XX:Tier4CompileThreshold=100 -jar "$JAR") - AOT_STEADY=$(avg_runs $STEADY_RUNS java -XX:Tier4CompileThreshold=100 -XX:AOTCache="$AOT" -jar "$JAR") - - # OG generator - OG_PY_STEADY=$(avg_runs $STEADY_RUNS python3 html-generators/generateog.py) - OG_JBANG_STEADY=$(avg_runs $STEADY_RUNS jbang html-generators/generateog.java) - OG_JAR_STEADY=$(avg_runs $STEADY_RUNS java -XX:Tier4CompileThreshold=100 -jar "$OG_JAR") - OG_AOT_STEADY=$(avg_runs $STEADY_RUNS java -XX:Tier4CompileThreshold=100 -XX:AOTCache="$OG_AOT" -jar "$OG_JAR") - - # Write to GitHub Actions Job Summary - { - echo "## Benchmark Results — \`$os_name\`" - echo "" - echo "Java $java_ver · $snippet_count snippets" - echo "" - echo "### HTML Generator" - echo "" - echo "#### Phase 1: Training / Build Cost (one-time)" - echo "" - echo "| Step | Time | What it does |" - echo "|------|------|-------------|" - echo "| Python first run | ${PY_TRAIN}s | Interprets source, creates \`__pycache__\` bytecode |" - echo "| JBang export | ${JBANG_EXPORT}s | Compiles source + bundles dependencies into fat JAR |" - echo "| AOT training run | ${AOT_TRAIN}s | Runs JAR once to record class loading, produces \`.aot\` cache |" - echo "" - echo "#### Phase 2: Steady-State Execution (avg of $STEADY_RUNS runs)" - echo "" - echo "| Method | Avg Time |" - echo "|--------|---------|" - echo "| **Fat JAR + AOT** | **${AOT_STEADY}s** |" - echo "| **Fat JAR** | ${JAR_STEADY}s |" - echo "| **JBang** | ${JBANG_STEADY}s |" - echo "| **Python** | ${PY_STEADY}s |" - echo "" - echo "### OG Card Generator" - echo "" - echo "#### Phase 1: Training / Build Cost (one-time)" - echo "" - echo "| Step | Time | What it does |" - echo "|------|------|-------------|" - echo "| Python first run | ${OG_PY_TRAIN}s | Interprets source, generates SVG+PNG via cairosvg |" - echo "| JBang export | ${OG_JBANG_EXPORT}s | Compiles source + bundles Batik dependencies into fat JAR |" - echo "| AOT training run | ${OG_AOT_TRAIN}s | Runs JAR once to record class loading, produces \`.aot\` cache |" - echo "" - echo "#### Phase 2: Steady-State Execution (avg of $STEADY_RUNS runs)" - echo "" - echo "| Method | Avg Time |" - echo "|--------|---------|" - echo "| **Fat JAR + AOT** | **${OG_AOT_STEADY}s** |" - echo "| **Fat JAR** | ${OG_JAR_STEADY}s |" - echo "| **JBang** | ${OG_JBANG_STEADY}s |" - echo "| **Python** | ${OG_PY_STEADY}s |" - } >> "$GITHUB_STEP_SUMMARY" - - # --------------------------------------------------------------------------- - # Phase 3: CI cold start — runs on a completely fresh runner. - # JAR and AOT are built once then passed via artifact, simulating - # the actions cache restore that happens in the deploy workflow. - # Python and JBang start with zero caches, just like real CI. - # --------------------------------------------------------------------------- - build-jar: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-java@v6 - with: - distribution: 'temurin' - java-version: '25' - - - uses: jbangdev/setup-jbang@main - - - name: Build fat JARs and AOT caches - run: | - jbang export fatjar --force --output html-generators/generate.jar html-generators/generate.java - java -XX:AOTCacheOutput=html-generators/generate.aot -jar html-generators/generate.jar - jbang export fatjar --force --output html-generators/generateog.jar html-generators/generateog.java - java -XX:AOTCacheOutput=html-generators/generateog.aot -jar html-generators/generateog.jar - - - name: Upload JAR and AOT - uses: actions/upload-artifact@v7 - with: - name: generator - path: | - html-generators/generate.jar - html-generators/generate.aot - html-generators/generateog.jar - html-generators/generateog.aot - - ci-cold-start: - needs: build-jar - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-java@v6 - with: - distribution: 'temurin' - java-version: '25' - - - name: Setup Python - uses: actions/setup-python@v7 - with: - python-version: '3.13' - - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libcairo2-dev - - - name: Install Python dependencies - run: pip install pyyaml cairosvg - - - name: Download JAR and AOT - uses: actions/download-artifact@v8 - with: - name: generator - path: html-generators - - - name: CI cold-start benchmark - shell: bash - run: | - os_name="ubuntu-latest" - java_ver=$(java -version 2>&1 | head -1 | sed 's/.*"\(.*\)".*/\1/') - snippet_count=$(find content \( -name '*.json' -o -name '*.yaml' -o -name '*.yml' \) -not -name 'template.*' | wc -l | tr -d ' ') - - _now() { python3 -c "import time; print(int(time.time()*1e9))"; } - - measure() { - local start end - start=$(_now) - if "$@" > /dev/null 2>&1; then - end=$(_now) - awk "BEGIN {printf \"%.2f\", ($end - $start) / 1000000000}" - else - echo "FAIL" - fi - } - - # Everything is cold: no __pycache__, no JBang cache, fresh JVM - - # HTML generator - PY_CI=$(measure python3 html-generators/generate.py) - JAR_CI=$(measure java -XX:Tier4CompileThreshold=100 -jar html-generators/generate.jar) - AOT_CI=$(measure java -XX:Tier4CompileThreshold=100 -XX:AOTCache=html-generators/generate.aot -jar html-generators/generate.jar) - - # OG generator - OG_PY_CI=$(measure python3 html-generators/generateog.py) - OG_JAR_CI=$(measure java -XX:Tier4CompileThreshold=100 -jar html-generators/generateog.jar) - OG_AOT_CI=$(measure java -XX:Tier4CompileThreshold=100 -XX:AOTCache=html-generators/generateog.aot -jar html-generators/generateog.jar) - - { - echo "## CI Cold Start — \`$os_name\`" - echo "" - echo "Java $java_ver · $snippet_count snippets" - echo "" - echo "Fresh runner, no caches. JARs and AOT restored from artifact" - echo "(simulates actions cache restore in deploy workflow)." - echo "" - echo "### HTML Generator" - echo "" - echo "| Method | Time |" - echo "|--------|------|" - echo "| **Fat JAR + AOT** | **${AOT_CI}s** |" - echo "| **Fat JAR** | ${JAR_CI}s |" - echo "| **Python** | ${PY_CI}s |" - echo "" - echo "### OG Card Generator" - echo "" - echo "| Method | Time |" - echo "|--------|------|" - echo "| **Fat JAR + AOT** | **${OG_AOT_CI}s** |" - echo "| **Fat JAR** | ${OG_JAR_CI}s |" - echo "| **Python** | ${OG_PY_CI}s |" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 936258f0..80e8de4a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ jwebserver -b 0.0.0.0 -d path/to/site -p 8090 The fat JAR is a self-contained ~2.2 MB file with all dependencies bundled. [JBang](https://jbang.dev) is needed to run the generator. -For development on the generator itself, you can use JBang or Python — see [html-generators/README.md](html-generators/README.md) for details. +For development on the generator itself, see [html-generators/README.md](html-generators/README.md). ## Contributing diff --git a/html-generators/README.md b/html-generators/README.md index 124a6673..406d82e0 100644 --- a/html-generators/README.md +++ b/html-generators/README.md @@ -7,14 +7,9 @@ This folder contains the build scripts that generate all HTML detail pages and ` | File | Description | |-----------------|-----------------------------------------------| | `generate.java` | JBang script (Java 25) — primary generator | -| `generate.py` | Python equivalent — produces identical output | | `generate.jar` | Pre-built fat JAR (no JBang/JDK setup needed) | | `build-cds.sh` | Script to build a platform-specific AOT cache | -## Benchmark - -See [benchmark/README.md](benchmark/README.md) for performance comparisons across all four execution methods (AOT, Fat JAR, JBang, Python). - ## Running ### Option 1: Fat JAR (fastest, no setup) @@ -45,14 +40,6 @@ jbang html-generators/generate.java Requires [JBang](https://jbang.dev) and Java 25+. -### Option 4: Python - -```bash -python3 html-generators/generate.py -``` - -Requires Python 3.8+. - ## Rebuilding the fat JAR After modifying `generate.java`, rebuild the fat JAR: diff --git a/html-generators/benchmark/LOCAL.md b/html-generators/benchmark/LOCAL.md deleted file mode 100644 index 46819611..00000000 --- a/html-generators/benchmark/LOCAL.md +++ /dev/null @@ -1,73 +0,0 @@ -# Local Benchmark Results - -Local benchmark results from `run.sh`. These will differ from CI because of OS file caching and warm `__pycache__/`. - -## Phase 1: Training / Build Cost (one-time) - -These are one-time setup costs, comparable across languages. - -| Step | Time | What it does | -|------|------|-------------| -| Python first run | 1.98s | Interprets source, creates `__pycache__` bytecode | -| JBang export | 2.19s | Compiles source + bundles dependencies into fat JAR | -| AOT training run | 2.92s | Runs JAR once to record class loading, produces `.aot` cache | - -## Phase 2: Steady-State Execution (avg of 5 runs) - -After one-time setup, these are the per-run execution times. - -| Method | Avg Time | Notes | -|--------|---------|-------| -| **Fat JAR + AOT** | **0.32s** | Fastest; pre-loaded classes from AOT cache | -| **Fat JAR** | 0.44s | JVM class loading on every run | -| **JBang** | 1.08s | Includes JBang launcher overhead | -| **Python** | 1.26s | Uses cached `__pycache__` bytecode | - -## Phase 3: CI Cold Start (simulated locally) - -Clears `__pycache__/` and JBang cache, then measures a single run. On a local machine the OS file cache still helps, so these numbers are faster than true CI. - -| Method | Time | Notes | -|--------|------|-------| -| **Fat JAR + AOT** | **0.46s** | AOT cache ships pre-loaded classes | -| **Fat JAR** | 0.40s | JVM class loading from scratch | -| **JBang** | 3.25s | Must compile source before running | -| **Python** | 0.16s | No `__pycache__`; full interpretation | - -## How each method works - -- **Python** caches compiled bytecode in `__pycache__/` after the first run, similar to how Java's AOT cache works. But this cache is local-only and not available in CI. -- **Java AOT** (JEP 483) snapshots ~3,300 pre-loaded classes from a training run into a `.aot` file, eliminating class loading overhead on subsequent runs. The `.aot` file is stored in the GitHub Actions cache. -- **JBang** compiles and caches internally but adds launcher overhead on every invocation. -- **Fat JAR** (`java -jar`) loads and links all classes from scratch each time. - -## AOT Cache Setup - -```bash -# One-time: build the fat JAR -jbang export fatjar --force --output html-generators/generate.jar html-generators/generate.java - -# One-time: build the AOT cache (~21 MB, platform-specific) -java -XX:AOTCacheOutput=html-generators/generate.aot -jar html-generators/generate.jar - -# Steady-state: run with AOT cache -java -XX:AOTCache=html-generators/generate.aot -jar html-generators/generate.jar -``` - -## Environment - -| | | -|---|---| -| **CPU** | Apple M1 Max | -| **RAM** | 32 GB | -| **Java** | OpenJDK 25.0.1 (Temurin) | -| **JBang** | 0.136.0 | -| **Python** | 3.14.3 | -| **OS** | Darwin | - -## Reproduce - -```bash -./html-generators/benchmark/run.sh # print results to stdout -./html-generators/benchmark/run.sh --update # also update this file -``` diff --git a/html-generators/benchmark/README.md b/html-generators/benchmark/README.md deleted file mode 100644 index 8968eef6..00000000 --- a/html-generators/benchmark/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Generator Benchmarks - -Performance comparison of execution methods for the HTML and OG card generators, measured on 112 snippets across 11 categories. - -## CI Benchmark (GitHub Actions) - -[![Benchmark Generator](https://github.com/javaevolved/javaevolved.github.io/actions/workflows/benchmark.yml/badge.svg)](https://github.com/javaevolved/javaevolved.github.io/actions/workflows/benchmark.yml) - -The most important benchmark runs on GitHub Actions because it measures performance in the environment where the generator actually executes — CI. The [Benchmark Generator](https://github.com/javaevolved/javaevolved.github.io/actions/workflows/benchmark.yml) workflow is manually triggered and runs across **Ubuntu**, **Windows**, and **macOS**. - -### Why CI benchmarks matter - -On a developer machine, repeated runs benefit from warm OS file caches — the operating system keeps recently read files in RAM, making subsequent reads nearly instant. This masks real-world performance differences. Python also benefits from `__pycache__/` bytecode that persists between runs. - -In CI, **every workflow run starts on a fresh runner**. There is no `__pycache__/`, no warm OS cache, no JBang compilation cache. This is the environment where the deploy workflow runs, so these numbers reflect actual production performance. - -### How the CI benchmark works - -The workflow has three jobs: - -1. **`benchmark`** — Runs Phase 1 (training/build costs) and Phase 2 (steady-state execution) on each OS. All tools are installed in the same job, so this measures raw execution speed after setup. - -2. **`build-jar`** — Builds the fat JAR and AOT cache on each OS, then uploads them as workflow artifacts. This simulates what the `build-generator.yml` workflow does weekly: produce the JAR and AOT cache and store them in the GitHub Actions cache. - -3. **`ci-cold-start`** — The key benchmark. Runs on a **completely fresh runner** that has never executed Java or Python in the current job. It downloads the JAR and AOT artifacts (simulating the `actions/cache/restore` step in the deploy workflow), then measures a single cold run of each method. This is the closest simulation of what happens when the deploy workflow runs: - - **Python** has no `__pycache__/` — it must interpret every `.py` file from scratch - - **Fat JAR** must load and link all classes on a cold JVM - - **Fat JAR + AOT** loads pre-linked classes from the `.aot` file, skipping class loading entirely - - The `setup-java` and `setup-python` actions are required to provide the runtimes, but they don't warm up the generator code. The first invocation of `java` or `python3` in this job is the benchmark measurement itself. - -### Why Java AOT wins in CI - -Java's AOT cache (JEP 483) snapshots the result of class loading and linking from a training run into a `.aot` file. This file is platform-specific and ~21 MB. When restored from the actions cache, the JVM skips the expensive class discovery, verification, and linking steps that normally happen on first run. - -Python's `__pycache__/` serves a similar purpose — it caches compiled bytecode so Python doesn't re-parse `.py` files. But `__pycache__/` is not committed to git or stored in CI caches, so **Python always pays full interpretation cost in CI**. Java AOT, by contrast, is stored in the actions cache and restored before each deploy. - -## Local Benchmark - -See [LOCAL.md](LOCAL.md) for local benchmark results and instructions to run on your own machine. diff --git a/html-generators/benchmark/run.sh b/html-generators/benchmark/run.sh deleted file mode 100755 index f0526199..00000000 --- a/html-generators/benchmark/run.sh +++ /dev/null @@ -1,299 +0,0 @@ -#!/usr/bin/env bash -# Benchmark the HTML and OG card generators across languages and execution methods. -# -# Phase 1: Training/build cost (one-time setup) -# - Python first run (creates __pycache__) -# - Java AOT training run (creates .aot) -# - JBang export (creates fat JAR) -# -# Phase 2: Steady-state execution (5 runs averaged) -# - Python (warm, with __pycache__) -# - JBang (from source) -# - Fat JAR (java -jar) -# - Fat JAR + AOT (java -XX:AOTCache) -# -# Usage: -# ./html-generators/benchmark/run.sh # print results to stdout -# ./html-generators/benchmark/run.sh --update # also update LOCAL.md - -set -euo pipefail -cd "$(git rev-parse --show-toplevel)" - -JAR="html-generators/generate.jar" -AOT="html-generators/generate.aot" -OG_JAR="html-generators/generateog.jar" -OG_AOT="html-generators/generateog.aot" -STEADY_RUNS=5 -UPDATE_MD=false -[[ "${1:-}" == "--update" ]] && UPDATE_MD=true - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -measure() { - local start end - start=$(python3 -c "import time; print(time.time())") - if "$@" > /dev/null 2>&1; then - end=$(python3 -c "import time; print(time.time())") - python3 -c "print(f'{$end - $start:.2f}')" - else - echo "FAIL" - fi -} - -avg_runs() { - local n="$1"; shift - local sum=0 success=0 - for ((i = 1; i <= n; i++)); do - local t - t=$(measure "$@") - if [[ "$t" != "FAIL" ]]; then - sum=$(echo "$sum + $t" | bc) - success=$((success + 1)) - fi - done - if [[ $success -eq 0 ]]; then - echo "FAIL" - else - echo "scale=2; $sum / $success" | bc | sed 's/^\./0./' - fi -} - -# --------------------------------------------------------------------------- -# Environment -# --------------------------------------------------------------------------- -CPU=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || lscpu 2>/dev/null | awk -F: '/Model name/ {gsub(/^ +/,"",$2); print $2}' || echo "unknown") -RAM=$(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%d GB", $1/1024/1024/1024}' || free -h 2>/dev/null | awk '/Mem:/ {print $2}' || echo "unknown") -JAVA_VER=$(java -version 2>&1 | head -1 | sed 's/.*"\(.*\)".*/\1/') -JBANG_VER=$(jbang version 2>/dev/null || echo "n/a") -PYTHON_VER=$(python3 --version 2>/dev/null | awk '{print $2}' || echo "n/a") -OS=$(uname -s) -SNIPPET_COUNT=$(find content \( -name '*.json' -o -name '*.yaml' -o -name '*.yml' \) -not -name 'template.*' | wc -l | tr -d ' ') - -echo "" -echo "Environment: $CPU · $RAM · Java $JAVA_VER · $OS" -echo "Snippets: $SNIPPET_COUNT across 11 categories" -echo "" - -# --------------------------------------------------------------------------- -# Phase 1: Training / build cost (one-time) -# --------------------------------------------------------------------------- -echo "=== Phase 1: Training / Build Cost (one-time) ===" -echo "" - -# Clean up any cached state -rm -f html-generators/generate.aot html-generators/generate.jar -rm -f html-generators/generateog.aot html-generators/generateog.jar -find html-generators -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true - -# Python first run (populates __pycache__) -PY_TRAIN=$(measure python3 html-generators/generate.py) -echo " Python first run (creates __pycache__): ${PY_TRAIN}s" - -# JBang export (creates fat JAR) -JBANG_EXPORT=$(measure jbang export fatjar --force --output "$JAR" html-generators/generate.java) -echo " JBang export (creates fat JAR): ${JBANG_EXPORT}s" - -# AOT training run (creates .aot from JAR) -AOT_TRAIN=$(measure java -XX:AOTCacheOutput="$AOT" -jar "$JAR") -echo " AOT training run (creates .aot): ${AOT_TRAIN}s" - -echo "" -echo "--- OG Card Generator ---" -echo "" - -OG_PY_TRAIN=$(measure python3 html-generators/generateog.py) -echo " Python first run (creates __pycache__): ${OG_PY_TRAIN}s" - -OG_JBANG_EXPORT=$(measure jbang export fatjar --force --output "$OG_JAR" html-generators/generateog.java) -echo " JBang export (creates fat JAR): ${OG_JBANG_EXPORT}s" - -OG_AOT_TRAIN=$(measure java -XX:AOTCacheOutput="$OG_AOT" -jar "$OG_JAR") -echo " AOT training run (creates .aot): ${OG_AOT_TRAIN}s" - -echo "" - -# --------------------------------------------------------------------------- -# Phase 2: Steady-state execution (averaged over $STEADY_RUNS runs) -# --------------------------------------------------------------------------- -echo "=== Phase 2: Steady-State Execution (avg of $STEADY_RUNS runs) ===" -echo "" - -PY_STEADY=$(avg_runs $STEADY_RUNS python3 html-generators/generate.py) -echo " Python (warm): ${PY_STEADY}s" - -JBANG_STEADY=$(avg_runs $STEADY_RUNS jbang html-generators/generate.java) -echo " JBang (from source): ${JBANG_STEADY}s" - -JAR_STEADY=$(avg_runs $STEADY_RUNS java -jar "$JAR") -echo " Fat JAR: ${JAR_STEADY}s" - -AOT_STEADY=$(avg_runs $STEADY_RUNS java -XX:AOTCache="$AOT" -jar "$JAR") -echo " Fat JAR + AOT: ${AOT_STEADY}s" - -echo "" -echo "--- OG Card Generator ---" -echo "" - -OG_PY_STEADY=$(avg_runs $STEADY_RUNS python3 html-generators/generateog.py) -echo " Python (warm): ${OG_PY_STEADY}s" - -OG_JBANG_STEADY=$(avg_runs $STEADY_RUNS jbang html-generators/generateog.java) -echo " JBang (from source): ${OG_JBANG_STEADY}s" - -OG_JAR_STEADY=$(avg_runs $STEADY_RUNS java -jar "$OG_JAR") -echo " Fat JAR: ${OG_JAR_STEADY}s" - -OG_AOT_STEADY=$(avg_runs $STEADY_RUNS java -XX:AOTCache="$OG_AOT" -jar "$OG_JAR") -echo " Fat JAR + AOT: ${OG_AOT_STEADY}s" - -echo "" - -# --------------------------------------------------------------------------- -# Phase 3: CI cold start (no caches, simulates fresh runner) -# --------------------------------------------------------------------------- -echo "=== Phase 3: CI Cold Start (fresh runner, no caches) ===" -echo "" - -find html-generators -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true -PY_CI=$(measure python3 html-generators/generate.py) -echo " Python (no __pycache__): ${PY_CI}s" - -jbang cache clear > /dev/null 2>&1 || true -JBANG_CI=$(measure jbang html-generators/generate.java) -echo " JBang (no cache): ${JBANG_CI}s" - -JAR_CI=$(measure java -jar "$JAR") -echo " Fat JAR: ${JAR_CI}s" - -AOT_CI=$(measure java -XX:AOTCache="$AOT" -jar "$JAR") -echo " Fat JAR + AOT: ${AOT_CI}s" - -echo "" -echo "--- OG Card Generator ---" -echo "" - -find html-generators -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true -OG_PY_CI=$(measure python3 html-generators/generateog.py) -echo " Python (no __pycache__): ${OG_PY_CI}s" - -OG_JAR_CI=$(measure java -jar "$OG_JAR") -echo " Fat JAR: ${OG_JAR_CI}s" - -OG_AOT_CI=$(measure java -XX:AOTCache="$OG_AOT" -jar "$OG_JAR") -echo " Fat JAR + AOT: ${OG_AOT_CI}s" - -echo "" - -# --------------------------------------------------------------------------- -# Optionally update LOCAL.md -# --------------------------------------------------------------------------- -if $UPDATE_MD; then - MD="html-generators/benchmark/LOCAL.md" - cat > "$MD" < 0: - props[line[:idx].strip()] = line[idx + 1:].strip() - return props - - -CATEGORY_DISPLAY = _load_properties(CATEGORIES_FILE) -LOCALES = _load_properties(LOCALES_FILE) - - -# --------------------------------------------------------------------------- -# File helpers (multi-format: .json, .yaml, .yml) -# --------------------------------------------------------------------------- - -def _find_with_extensions(directory, base_name): - """Return the first existing file matching base_name.{json,yaml,yml} in directory.""" - for ext in ("json", "yaml", "yml"): - p = os.path.join(directory, f"{base_name}.{ext}") - if os.path.isfile(p): - return p - return None - - -def _read_auto(path): - """Read a JSON or YAML file based on its extension.""" - with open(path, encoding="utf-8") as f: - if path.endswith(".yaml") or path.endswith(".yml"): - return yaml.safe_load(f) - return json.load(f) - - -# --------------------------------------------------------------------------- -# UI strings (i18n) -# --------------------------------------------------------------------------- - -def _flatten(obj, prefix=""): - """Flatten a nested dict into dot-separated keys.""" - flat = {} - for k, v in obj.items(): - key = f"{prefix}.{k}" if prefix else k - if isinstance(v, dict): - flat.update(_flatten(v, key)) - else: - flat[key] = str(v) - return flat - - -def load_strings(locale): - """Load UI strings for a locale with English fallback for missing keys.""" - en_path = _find_with_extensions(os.path.join(TRANSLATIONS_DIR, "strings"), "en") - if not en_path: - raise FileNotFoundError("No English strings file found") - en_strings = _flatten(_read_auto(en_path)) - - if locale == "en": - return en_strings - - locale_path = _find_with_extensions(os.path.join(TRANSLATIONS_DIR, "strings"), locale) - if not locale_path: - print(f"[WARN] strings/{locale}.{{json,yaml,yml}} not found — using all English strings") - return dict(en_strings) - - locale_strings = _flatten(_read_auto(locale_path)) - merged = dict(en_strings) - for key, value in locale_strings.items(): - if key in en_strings: - merged[key] = value - # Warn about missing keys - locale_file = os.path.basename(locale_path) - for key in en_strings: - if key not in locale_strings: - print(f'[WARN] {locale_file}: missing key "{key}" — using English fallback') - return merged - - -# --------------------------------------------------------------------------- -# Snippet helpers -# --------------------------------------------------------------------------- - -def _get(data, field): - return data[field] - - -def _opt(data, field): - v = data.get(field) - return v if v is not None else None - - -def _key(data): - return f"{data['category']}/{data['slug']}" - - -def _cat_display(data): - return CATEGORY_DISPLAY[data["category"]] - - -# --------------------------------------------------------------------------- -# Escape helpers -# --------------------------------------------------------------------------- - -def escape(text): - """HTML-escape text for use in attributes and content.""" - if text is None: - return "" - return html_mod.escape(str(text), quote=True) - - -def json_escape(text): - """Escape text for embedding in JSON strings inside ld+json blocks. - Uses ASCII-only encoding with \\uXXXX escapes for non-ASCII characters. - """ - return json.dumps(text, ensure_ascii=True)[1:-1] - - -def js_escape(s): - """Escape a string for embedding inside a JS double-quoted string.""" - return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") - - -def url_encode(s): - return quote(s, safe="") - - -# --------------------------------------------------------------------------- -# Token replacement (multi-pass, supports dotted keys) -# --------------------------------------------------------------------------- - -def replace_tokens(template, replacements): - """Replace {{token}} placeholders; up to 3 passes for nested tokens.""" - result = template - for _ in range(3): - found = False - def replacer(m): - nonlocal found - key = m.group(1) - val = replacements.get(key) - if val is not None: - found = True - return val - return m.group(0) - result = TOKEN_RE.sub(replacer, result) - if not found: - break - return result - - -# --------------------------------------------------------------------------- -# Load all English snippets -# --------------------------------------------------------------------------- - -def load_all_snippets(): - """Load all content snippet files, keyed by category/slug, in sorted order.""" - snippets = OrderedDict() - for cat in CATEGORY_DISPLAY: - cat_dir = os.path.join(CONTENT_DIR, cat) - if not os.path.isdir(cat_dir): - continue - files = [] - for ext in ("json", "yaml", "yml"): - files.extend(glob.glob(os.path.join(cat_dir, f"*.{ext}"))) - files.sort() - for path in files: - data = _read_auto(path) - data["_path"] = _key(data) - snippets[_key(data)] = data - return snippets - - -# --------------------------------------------------------------------------- -# Translation merging -# --------------------------------------------------------------------------- - -def resolve_snippet(english_snippet, locale): - """Overlay translated content onto the English base for a given locale.""" - if locale == "en": - return english_snippet - - translated_dir = os.path.join(TRANSLATIONS_DIR, "content", locale, english_snippet["category"]) - translated_path = _find_with_extensions(translated_dir, english_snippet["slug"]) - if not translated_path: - return english_snippet - - try: - translated = _read_auto(translated_path) - merged = copy.deepcopy(english_snippet) - for field in TRANSLATABLE_FIELDS: - if field in translated: - if field == "support" and isinstance(translated["support"], dict): - if "description" in translated["support"]: - merged["support"]["description"] = translated["support"]["description"] - else: - merged[field] = translated[field] - return merged - except Exception: - print(f"[WARN] Failed to load {translated_path} — using English") - return english_snippet - - -# --------------------------------------------------------------------------- -# Badge / display helpers -# --------------------------------------------------------------------------- - -def support_badge(state, strings): - return { - "preview": strings.get("support.preview", "Preview"), - "experimental": strings.get("support.experimental", "Experimental"), - }.get(state, strings.get("support.available", "Available")) - - -def support_badge_class(state): - return {"preview": "preview", "experimental": "experimental"}.get(state, "widely") - - -def difficulty_display(difficulty, strings): - return strings.get(f"difficulty.{difficulty}", difficulty) - - -# --------------------------------------------------------------------------- -# Render helpers -# --------------------------------------------------------------------------- - -def render_nav_arrows(data, locale): - """Render prev/next navigation arrows with locale-aware paths.""" - prefix = "" if locale == "en" else f"/{locale}" - prev_val = _opt(data, "prev") - next_val = _opt(data, "next") - prev_html = ( - f'' - if prev_val - else '' - ) - next_html = ( - f'' - if next_val - else "" - ) - return prev_html + "\n " + next_html - - -def render_why_cards(tpl, why_list): - """Render the 3 why-modern-wins cards.""" - cards = [] - for w in why_list: - cards.append(replace_tokens(tpl, { - "icon": w["icon"], - "title": escape(w["title"]), - "desc": escape(w["desc"]), - })) - return "\n".join(cards) - - -def render_doc_links(tpl, docs): - """Render documentation links.""" - return "\n".join( - replace_tokens(tpl, { - "docTitle": escape(d["title"]), - "docHref": d["href"], - }) - for d in docs - ) - - -def render_related_card(tpl, rel, locale, strings): - """Render a single related pattern tip-card.""" - related_href = ( - f"/{rel['category']}/{rel['slug']}.html" - if locale == "en" - else f"/{locale}/{rel['category']}/{rel['slug']}.html" - ) - return replace_tokens(tpl, { - "category": rel["category"], - "slug": rel["slug"], - "catDisplay": _cat_display(rel), - "difficulty": rel["difficulty"], - "difficultyDisplay": difficulty_display(rel["difficulty"], strings), - "title": escape(rel["title"]), - "oldLabel": escape(rel["oldLabel"]), - "oldCode": escape(rel["oldCode"]), - "modernLabel": escape(rel["modernLabel"]), - "modernCode": escape(rel["modernCode"]), - "jdkVersion": rel["jdkVersion"], - "relatedHref": related_href, - "cards.hoverHintRelated": strings.get("cards.hoverHintRelated", "Hover to see modern ➜"), - }) - - -def render_related_section(tpl, data, all_snippets, locale, strings): - """Render all related pattern cards.""" - related = data.get("related", []) - cards = [] - for path in related: - if path in all_snippets: - cards.append(render_related_card(tpl, all_snippets[path], locale, strings)) - return "\n".join(cards) - - -def slug_to_pascal_case(slug): - """Convert a hyphen-delimited slug to PascalCase. E.g. 'type-inference-with-var' -> 'TypeInferenceWithVar'.""" - return "".join(w.capitalize() for w in slug.split("-") if w) - - -def render_proof_section(data, strings): - """Render the proof section linking to the proof source file on GitHub, or empty string if no proof exists.""" - slug = data["slug"] - category = data["category"] - pascal = slug_to_pascal_case(slug) - proof_file = os.path.join("proof", category, f"{pascal}.java") - if not os.path.isfile(proof_file): - return "" - proof_url = f"https://github.com/javaevolved/javaevolved.github.io/blob/main/proof/{category}/{pascal}.java" - label = strings.get("sections.proof", "Proof") - link_text = strings.get("sections.proofLink", "View proof source") - return ( - '
\n' - f' \n' - ' \n' - '
' - ) - - -def render_social_share(tpl, category, slug, title, strings): - """Render social share URLs.""" - encoded_url = url_encode(f"{BASE_URL}/{category}/{slug}.html") - encoded_text = url_encode(f"{title} \u2013 java.evolved") - return replace_tokens(tpl, { - "encodedUrl": encoded_url, - "encodedText": encoded_text, - "share.label": strings.get("share.label", "Share"), - }) - - -def render_index_card(tpl, data, locale, strings): - """Render a single index page preview card.""" - card_href = ( - f"/{data['category']}/{data['slug']}.html" - if locale == "en" - else f"/{locale}/{data['category']}/{data['slug']}.html" - ) - return replace_tokens(tpl, { - "category": data["category"], - "slug": data["slug"], - "catDisplay": _cat_display(data), - "title": escape(data["title"]), - "oldCode": escape(data["oldCode"]), - "modernCode": escape(data["modernCode"]), - "jdkVersion": data["jdkVersion"], - "cardHref": card_href, - "cards.old": strings.get("cards.old", "Old"), - "cards.modern": strings.get("cards.modern", "Modern"), - "cards.hoverHint": strings.get("cards.hoverHint", "hover to see modern →"), - "cards.learnMore": strings.get("cards.learnMore", "learn more"), - }) - - -# --------------------------------------------------------------------------- -# Locale picker, hreflang, i18n script -# --------------------------------------------------------------------------- - -def render_locale_picker(current_locale): - """Render the locale picker dropdown HTML.""" - lines = [] - lines.append('
') - lines.append(' ') - lines.append(' ") - lines.append("
") - return "\n".join(lines) - - -def render_hreflang_links(path_part, slug): - """Render hreflang tags for all locales.""" - lines = [] - for loc in LOCALES: - if slug == "index": - href = f"{BASE_URL}/" if loc == "en" else f"{BASE_URL}/{loc}/" - else: - href = ( - f"{BASE_URL}/{path_part}{slug}.html" - if loc == "en" - else f"{BASE_URL}/{loc}/{path_part}{slug}.html" - ) - lines.append(f' ') - # x-default points to English - default_href = ( - f"{BASE_URL}/" if slug == "index" - else f"{BASE_URL}/{path_part}{slug}.html" - ) - lines.append(f' ') - return "\n".join(lines) - - -def render_i18n_script(strings, locale): - """Render the i18n script block for client-side JS.""" - locale_array = ", ".join(f'"{loc}"' for loc in LOCALES) - return ( - "" - ) - - -# --------------------------------------------------------------------------- -# Contribute URLs -# --------------------------------------------------------------------------- - -def build_contribute_urls(data, locale, locale_name): - """Build GitHub issue template URLs for contribute links.""" - title = data["title"] - category = data["category"] - slug = data["slug"] - - code_url = ( - f"{GITHUB_ISSUES_URL}?template=code-issue.yml" - f"&title={url_encode(f'[Code Issue] {title}')}" - f"&category={url_encode(category)}" - f"&slug={url_encode(slug)}" - ) - - clean_locale_name = re.sub(r"^[^\w]", "", locale_name, flags=re.UNICODE) - # Strip leading non-letter chars (match Java's ^[^\p{L}]+) - clean_locale_name = re.sub(r"^[^a-zA-Z\u00C0-\u024F\u0400-\u04FF\u0600-\u06FF\u3000-\u9FFF\uAC00-\uD7AF]+", "", locale_name) - - trans_url = ( - f"{GITHUB_ISSUES_URL}?template=translation-issue.yml" - f"&title={url_encode(f'[Translation] {title} ({clean_locale_name})')}" - f"&locale={url_encode(locale)}" - f"&pattern={url_encode(slug)}" - f"&area={url_encode('Pattern content')}" - ) - - suggest_url = f"{GITHUB_ISSUES_URL}?template=new-pattern.yml" - - return { - "contributeCodeIssueUrl": code_url, - "contributeTranslationIssueUrl": trans_url, - "contributeSuggestUrl": suggest_url, - } - - -# --------------------------------------------------------------------------- -# HTML generation -# --------------------------------------------------------------------------- - -def generate_html(templates, data, all_snippets, extra_tokens, locale): - """Generate the full HTML page for a snippet by rendering the template.""" - is_english = locale == "en" - cat = data["category"] - slug = data["slug"] - cat_display = _cat_display(data) - - canonical_url = ( - f"{BASE_URL}/{cat}/{slug}.html" - if is_english - else f"{BASE_URL}/{locale}/{cat}/{slug}.html" - ) - - tokens = dict(extra_tokens) - tokens.update({ - "title": escape(data["title"]), - "summary": escape(data["summary"]), - "slug": slug, - "category": cat, - "categoryDisplay": cat_display, - "difficulty": data["difficulty"], - "difficultyDisplay": difficulty_display(data["difficulty"], extra_tokens), - "jdkVersion": data["jdkVersion"], - "oldLabel": escape(data["oldLabel"]), - "modernLabel": escape(data["modernLabel"]), - "oldCode": escape(data["oldCode"]), - "modernCode": escape(data["modernCode"]), - "oldApproach": escape(data["oldApproach"]), - "modernApproach": escape(data["modernApproach"]), - "explanation": escape(data["explanation"]), - "supportDescription": escape(data["support"]["description"]), - "supportBadge": support_badge(data["support"]["state"], extra_tokens), - "supportBadgeClass": support_badge_class(data["support"]["state"]), - "canonicalUrl": canonical_url, - "flatUrl": f"{BASE_URL}/{slug}.html", - "titleJson": json_escape(data["title"]), - "summaryJson": json_escape(data["summary"]), - "categoryDisplayJson": json_escape(cat_display), - "navArrows": render_nav_arrows(data, locale), - "whyCards": render_why_cards(templates["why_card"], data["whyModernWins"]), - "docLinks": render_doc_links(templates["doc_link"], data.get("docs", [])), - "proofSection": render_proof_section(data, extra_tokens), - "relatedCards": render_related_section( - templates["related_card"], data, all_snippets, locale, extra_tokens - ), - "ogImage": f"{BASE_URL}/og/{cat}/{slug}.png", - "socialShare": render_social_share( - templates["social_share"], cat, slug, data["title"], extra_tokens - ), - }) - - locale_name = LOCALES.get(locale, locale) - tokens.update(build_contribute_urls(data, locale, locale_name)) - - return replace_tokens(templates["page"], tokens) - - -# --------------------------------------------------------------------------- -# Build a single locale -# --------------------------------------------------------------------------- - -def build_locale(locale, templates, all_snippets): - """Build all HTML files for a single locale.""" - is_english = locale == "en" - strings = load_strings(locale) - locale_name = LOCALES.get(locale, locale) - base_prefix = "../" if is_english else "../../" - home_url = "/" if is_english else f"/{locale}/" - - print(f"Building locale: {locale} ({locale_name})") - - locale_picker_html = render_locale_picker(locale) - index_hreflang = render_hreflang_links("", "index") - i18n_script = render_i18n_script(strings, locale) - - for snippet in all_snippets.values(): - resolved = resolve_snippet(snippet, locale) - detail_hreflang = render_hreflang_links(f"{snippet['category']}/", snippet["slug"]) - - extra_tokens = dict(strings) - extra_tokens.update({ - "locale": locale, - "htmlDir": "rtl" if locale == "ar" else "ltr", - "ogLocale": locale.replace("-", "_"), - "basePrefix": base_prefix, - "homeUrl": home_url, - "localePicker": locale_picker_html, - "hreflangLinks": detail_hreflang, - "i18nScript": i18n_script, - }) - - html_content = generate_html(templates, resolved, all_snippets, extra_tokens, locale).strip() - - if is_english: - out_dir = os.path.join(SITE_DIR, snippet["category"]) - else: - out_dir = os.path.join(SITE_DIR, locale, snippet["category"]) - os.makedirs(out_dir, exist_ok=True) - out_path = os.path.join(out_dir, f"{snippet['slug']}.html") - with open(out_path, "w", newline="", encoding="utf-8") as f: - f.write(html_content) - - print(f"Generated {len(all_snippets)} HTML files for {locale}") - - # Rebuild data/snippets.json - snippets_list = [] - for s in all_snippets.values(): - resolved = resolve_snippet(s, locale) - entry = {k: v for k, v in resolved.items() if k not in EXCLUDED_KEYS} - snippets_list.append(entry) - - data_dir = ( - os.path.join(SITE_DIR, "data") - if is_english - else os.path.join(SITE_DIR, locale, "data") - ) - os.makedirs(data_dir, exist_ok=True) - with open(os.path.join(data_dir, "snippets.json"), "w", encoding="utf-8") as f: - json.dump(snippets_list, f, indent=2, ensure_ascii=False) - f.write("\n") - print(f"Rebuilt data/snippets.json for {locale} with {len(snippets_list)} entries") - - # Generate index.html from template - tip_cards = "\n".join( - render_index_card(templates["index_card"], resolve_snippet(s, locale), locale, strings) - for s in all_snippets.values() - ) - - index_tokens = dict(strings) - index_tokens.update({ - "tipCards": tip_cards, - "snippetCount": str(len(all_snippets)), - "locale": locale, - "htmlDir": "rtl" if locale == "ar" else "ltr", - "ogLocale": locale.replace("-", "_"), - "canonicalUrl": BASE_URL if is_english else f"{BASE_URL}/{locale}", - "homeUrl": home_url, - "indexBasePrefix": "" if is_english else "../", - "localePicker": locale_picker_html, - "hreflangLinks": index_hreflang, - "i18nScript": i18n_script, - }) - - index_html = replace_tokens(templates["index"], index_tokens) - if is_english: - index_path = os.path.join(SITE_DIR, "index.html") - else: - index_dir = os.path.join(SITE_DIR, locale) - os.makedirs(index_dir, exist_ok=True) - index_path = os.path.join(index_dir, "index.html") - with open(index_path, "w", encoding="utf-8") as f: - f.write(index_html) - print(f"Generated index.html for {locale} with {len(all_snippets)} cards") - - -# --------------------------------------------------------------------------- -# Templates loader -# --------------------------------------------------------------------------- - -def load_templates(): - """Load all HTML templates.""" - def _read(path): - with open(path, encoding="utf-8") as f: - return f.read() - return { - "page": _read("templates/slug-template.html"), - "why_card": _read("templates/why-card.html"), - "related_card": _read("templates/related-card.html"), - "social_share": _read("templates/social-share.html"), - "index": _read("templates/index.html"), - "index_card": _read("templates/index-card.html"), - "doc_link": _read("templates/doc-link.html"), - } - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser(description="Generate java.evolved site HTML") - parser.add_argument("--all-locales", action="store_true", help="Build all locales") - parser.add_argument("--locale", type=str, help="Build a single locale") - args = parser.parse_args() - - templates = load_templates() - all_snippets = load_all_snippets() - print(f"Loaded {len(all_snippets)} snippets") - - if args.locale: - locales_to_build = [args.locale] - else: - locales_to_build = list(LOCALES.keys()) - - for locale in locales_to_build: - build_locale(locale, templates, all_snippets) - - -if __name__ == "__main__": - main() diff --git a/html-generators/generateog.py b/html-generators/generateog.py deleted file mode 100644 index 2d2e4d9a..00000000 --- a/html-generators/generateog.py +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate Open Graph SVG+PNG cards (1200×630) for each pattern. -Light theme, side-by-side Old/Modern code, slug title at top. -Python equivalent of generateog.java — produces identical output. - -Usage: python html-generators/generateog.py [category/slug] - No arguments → generate all patterns. - -Requires: cairosvg (pip install cairosvg) -""" - -import json -import os -import re -import sys -import glob as glob_mod -from collections import OrderedDict - -try: - import yaml -except ImportError: - yaml = None - -try: - import cairosvg -except ImportError: - cairosvg = None - -CONTENT_DIR = "content" -OUTPUT_DIR = "site/og" -CATEGORIES_FILE = "html-generators/categories.properties" - -# ── Light-theme palette ───────────────────────────────────────────────── -BG = "#ffffff" -BORDER = "#d8d8e0" -TEXT = "#1a1a2e" -TEXT_MUTED = "#6b7280" -OLD_BG = "#fef2f2" -MODERN_BG = "#eff6ff" -OLD_ACCENT = "#dc2626" -GREEN = "#059669" -ACCENT = "#6366f1" -BADGE_BG = "#f3f4f6" - -# ── Syntax highlight colors (VS Code light-inspired) ──────────────────── -SYN_KEYWORD = "#7c3aed" -SYN_TYPE = "#0e7490" -SYN_STRING = "#059669" -SYN_COMMENT = "#6b7280" -SYN_ANNOTATION = "#b45309" -SYN_NUMBER = "#c2410c" -SYN_DEFAULT = "#1a1a2e" - -JAVA_KEYWORDS = { - "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", - "class", "const", "continue", "default", "do", "double", "else", "enum", - "extends", "final", "finally", "float", "for", "goto", "if", "implements", - "import", "instanceof", "int", "interface", "long", "native", "new", "null", - "package", "private", "protected", "public", "record", "return", "sealed", - "short", "static", "strictfp", "super", "switch", "synchronized", "this", - "throw", "throws", "transient", "try", "var", "void", "volatile", "when", - "while", "with", "yield", "permits", "non-sealed", "module", "open", "opens", - "requires", "exports", "provides", "to", "uses", "transitive", - "true", "false", -} - -SYN_PATTERN = re.compile( - r"(?P//.*)|" - r"(?P/\*.*?\*/)|" - r"(?P@\w+)|" - r'(?P"""[\s\S]*?"""|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\')|' - r"(?P\b\d[\d_.]*[dDfFlL]?\b)|" - r"(?P\b[A-Za-z_]\w*\b)|" - r"(?P[^\s])" -) - -# ── Dimensions ────────────────────────────────────────────────────────── -W = 1200 -H = 630 -PAD = 40 -HEADER_H = 100 -FOOTER_H = 56 -CODE_TOP = HEADER_H -CODE_H = H - HEADER_H - FOOTER_H -COL_W = (W - PAD * 2 - 20) // 2 -CODE_PAD = 14 -LABEL_H = 32 -USABLE_W = COL_W - CODE_PAD * 2 -USABLE_H = CODE_H - LABEL_H - CODE_PAD -CHAR_WIDTH_RATIO = 0.6 -LINE_HEIGHT_RATIO = 1.55 -MIN_CODE_FONT = 9 -MAX_CODE_FONT = 16 - - -# ── Helpers ───────────────────────────────────────────────────────────── - -def load_properties(path): - props = OrderedDict() - with open(path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#"): - continue - idx = line.find("=") - if idx > 0: - props[line[:idx].strip()] = line[idx + 1:].strip() - return props - - -CATEGORY_DISPLAY = load_properties(CATEGORIES_FILE) - - -def read_auto(path): - with open(path, encoding="utf-8") as f: - if path.endswith((".yaml", ".yml")): - if yaml is None: - raise ImportError("PyYAML is required for YAML files: pip install pyyaml") - return yaml.safe_load(f) - return json.load(f) - - -def xml_escape(s): - if s is None: - return "" - return (s.replace("&", "&").replace("<", "<").replace(">", ">") - .replace('"', """).replace("'", "'")) - - -def load_all_snippets(): - snippets = OrderedDict() - for cat in CATEGORY_DISPLAY: - cat_dir = os.path.join(CONTENT_DIR, cat) - if not os.path.isdir(cat_dir): - continue - files = [] - for ext in ("json", "yaml", "yml"): - files.extend(glob_mod.glob(os.path.join(cat_dir, f"*.{ext}"))) - files.sort() - for path in files: - data = read_auto(path) - key = f"{data['category']}/{data['slug']}" - snippets[key] = data - return snippets - - -# ── Syntax highlighting ───────────────────────────────────────────────── - -def highlight_line(line): - if line == "...": - return xml_escape(line) - result = [] - last = 0 - for m in SYN_PATTERN.finditer(line): - if m.start() > last: - result.append(xml_escape(line[last:m.start()])) - last = m.end() - token = m.group() - color = None - if m.group("comment") or m.group("blockcomment"): - color = SYN_COMMENT - elif m.group("annotation"): - color = SYN_ANNOTATION - elif m.group("string"): - color = SYN_STRING - elif m.group("number"): - color = SYN_NUMBER - elif m.group("word"): - if token in JAVA_KEYWORDS: - color = SYN_KEYWORD - elif token[0].isupper(): - color = SYN_TYPE - if color: - result.append(f'{xml_escape(token)}') - else: - result.append(xml_escape(token)) - if last < len(line): - result.append(xml_escape(line[last:])) - return "".join(result) - - -# ── SVG rendering ─────────────────────────────────────────────────────── - -def best_font_size(old_lines, modern_lines): - max_chars = max( - max((len(l) for l in old_lines), default=1), - max((len(l) for l in modern_lines), default=1), - ) - max_lines = max(len(old_lines), len(modern_lines)) - by_width = int(USABLE_W / (max_chars * CHAR_WIDTH_RATIO)) - by_height = int(USABLE_H / (max_lines * LINE_HEIGHT_RATIO)) - return max(MIN_CODE_FONT, min(MAX_CODE_FONT, min(by_width, by_height))) - - -def fit_lines(lines, font_size): - line_h = int(font_size * LINE_HEIGHT_RATIO) - max_lines = USABLE_H // line_h - if len(lines) <= max_lines: - return lines - truncated = list(lines[:max_lines - 1]) - truncated.append("...") - return truncated - - -def render_code_block(lines, x, y, line_h): - parts = [] - for i, line in enumerate(lines): - parts.append( - f' ' - f'{highlight_line(line)}\n' - ) - return "".join(parts) - - -def generate_svg(data): - left_x = PAD - right_x = PAD + COL_W + 20 - label_y = CODE_TOP + 26 - code_y = CODE_TOP + 52 - - old_lines = data["oldCode"].split("\n") - modern_lines = data["modernCode"].split("\n") - - font_size = best_font_size(old_lines, modern_lines) - line_h = int(font_size * LINE_HEIGHT_RATIO) - - old_lines = fit_lines(old_lines, font_size) - modern_lines = fit_lines(modern_lines, font_size) - - cat_display = CATEGORY_DISPLAY.get(data["category"], data["category"]) - badge_width = len(cat_display) * 8 + 16 - - old_code_svg = render_code_block(old_lines, left_x + 14, code_y, line_h) - modern_code_svg = render_code_block(modern_lines, right_x + 14, code_y, line_h) - - return f""" - - - - - - - - - - - - - - - - - - {xml_escape(cat_display)} - {xml_escape(data['title'])} - - - - - \u2717 {xml_escape(data['oldLabel'])} - -{old_code_svg} - - - - - \u2713 {xml_escape(data['modernLabel'])} - -{modern_code_svg} - - - JDK {data['jdkVersion']}+ - javaevolved.github.io - -""" - - -def svg_to_png(svg_content, png_path): - if cairosvg is None: - raise ImportError("cairosvg is required for PNG generation: pip install cairosvg") - cairosvg.svg2png( - bytestring=svg_content.encode("utf-8"), - write_to=png_path, - output_width=W * 2, - output_height=H * 2, - ) - - -# ── Main ──────────────────────────────────────────────────────────────── - -def main(): - all_snippets = load_all_snippets() - print(f"Loaded {len(all_snippets)} snippets") - - # Filter to a single slug if provided - if len(sys.argv) > 1: - key = sys.argv[1] - if key not in all_snippets: - print(f"Unknown pattern: {key}") - print(f"Available: {', '.join(all_snippets.keys())}") - sys.exit(1) - targets = {key: all_snippets[key]} - else: - targets = all_snippets - - count = 0 - for key, data in targets.items(): - cat = data["category"] - slug = data["slug"] - out_dir = os.path.join(OUTPUT_DIR, cat) - os.makedirs(out_dir, exist_ok=True) - - svg = generate_svg(data) - svg_path = os.path.join(out_dir, f"{slug}.svg") - with open(svg_path, "w", encoding="utf-8") as f: - f.write(svg) - - png_path = os.path.join(out_dir, f"{slug}.png") - svg_to_png(svg, png_path) - count += 1 - - print(f"Generated {count} SVG+PNG card(s) in {OUTPUT_DIR}/") - - -if __name__ == "__main__": - main()