diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c637dce..795264e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,192 +1,106 @@ # Copilot Instructions for java.evolved -## Project Overview +## Build & Serve -This is a static site showcasing modern Java patterns vs legacy approaches. -It is hosted on GitHub Pages at https://javaevolved.github.io. +```bash +jbang html-generators/generate.java # Rebuild all locales +jbang html-generators/generate.java --locale es # Rebuild single locale +jwebserver -b 0.0.0.0 -d site -p 8090 # Serve locally +``` + +Requires **Java 25+** and **JBang**. No npm, Maven, or Gradle. + +**Validation:** There is no test suite. Validate changes by running the generator and confirming it completes without errors. The `proof/` directory contains one JBang script per pattern that proves the modern code compiles: + +```bash +jbang proof/language/TypeInferenceWithVar.java # Run single proof +``` ## Architecture -### Source of Truth: JSON Files +Static site generator: YAML content → JBang generator → HTML pages. -Each pattern is defined as a JSON file under **`content/category/`**: +- **`content/`** — Source of truth. One YAML/JSON file per pattern, organized by category. +- **`templates/`** — HTML templates with `{{placeholder}}` tokens. The generator replaces tokens with content fields and UI strings. +- **`html-generators/generate.java`** — JBang script that reads content + translations + templates and produces all HTML under `site/`. +- **`site/app.js`** and **`site/styles.css`** — Manually maintained client-side code (vanilla JS, no frameworks). +- **`translations/strings/{locale}.yaml`** — UI string translations (labels, nav, footer). Tokens use dotted keys: `{{nav.allPatterns}}`. +- **`translations/content/{locale}/`** — Partial content translations (only translatable fields; structural data always comes from English source). +- **`proof/{category}/{PascalCaseSlug}.java`** — JBang scripts proving each pattern's modern code compiles on Java 25. -``` -content/language/type-inference-with-var.json -content/collections/immutable-list-creation.json -content/streams/stream-tolist.json -... -``` +### Generated files — DO NOT EDIT directly -**Categories:** `language`, `collections`, `strings`, `streams`, `concurrency`, `io`, `errors`, `datetime`, `security`, `tooling`, `enterprise` +Everything under `site/` except `app.js` and `styles.css` is generated: +- `site/index.html`, `site/{category}/*.html`, `site/data/snippets.json` +- `site/{locale}/index.html`, `site/{locale}/{category}/*.html`, `site/{locale}/data/snippets.json` -### Generated Files (DO NOT EDIT) +Run the generator to rebuild after any content, template, or translation change. -The following are **generated by `html-generators/generate.java`** and must not be edited directly: +## Content Schema -- `site/index.html` — English homepage with preview cards (generated from `templates/index.html`) -- `site/language/*.html`, `site/collections/*.html`, etc. — English detail pages -- `site/data/snippets.json` — English aggregated search index -- `site/{locale}/index.html` — localized homepage (e.g., `site/es/index.html`) -- `site/{locale}/language/*.html`, etc. — localized detail pages -- `site/{locale}/data/snippets.json` — localized search index +Content files are YAML (preferred) or JSON under `content/{category}/{slug}.yaml`. The generator auto-detects format by extension (`.yaml`, `.yml`, `.json`). -Run `jbang html-generators/generate.java` to rebuild all generated files from the JSON sources and translations. +### Required fields -### Manually Maintained Files +| Field | Constraint | +|-------|-----------| +| `slug` | Must match filename (without extension) | +| `category` | Must match parent folder name | +| `whyModernWins` | Exactly **3** entries, each with `icon`, `title`, `desc` | +| `related` | Exactly **3** entries as `category/slug` paths (cross-category OK) | +| `docs` | At least **1** entry with `title` and `href` | +| `prev` / `next` | `category/slug` path or `null` for first/last in the global chain | +| `jdkVersion` | The JDK version where the feature became **final** (not preview) | +| `difficulty` | One of: `beginner`, `intermediate`, `advanced` | +| `support.state` | One of: `available`, `preview`, `experimental` | -- `site/app.js` — client-side search, filtering, code highlighting, locale detection -- `site/styles.css` — all styling -- `templates/slug-template.html` — HTML template with `{{placeholder}}` tokens (content + UI strings) used by the generator -- `templates/index.html` — homepage template with `{{tipCards}}`, `{{snippetCount}}`, and UI string placeholders -- `templates/index-card.html` — preview card template for the homepage grid -- `html-generators/categories.properties` — category ID → display name mapping -- `html-generators/locales.properties` — supported locales registry (locale=Display name) -- `translations/strings/{locale}.yaml` — UI strings per locale (labels, nav, footer, etc.) -- `translations/content/{locale}/` — translated pattern JSON files (partial, translatable fields only) +### Adding a new pattern -### Project Structure +1. Create `content/{category}/new-slug.yaml` with all required fields (use `content/template.json` as reference). +2. Update `prev`/`next` in the adjacent patterns to maintain the navigation chain. +3. Create `proof/{category}/{PascalCaseSlug}.java` — JBang script wrapping the modern code. +4. Run `jbang html-generators/generate.java` and verify it completes. +5. Translations are optional — the AI translation workflow handles them, or create partial files under `translations/content/{locale}/`. -``` -content/ # English content JSON files (source of truth, one per pattern) -translations/ # All i18n artifacts - strings/ # UI strings per locale (en.yaml, es.yaml, pt-BR.yaml) - content/ # Translated pattern files per locale (partial, translatable fields only) - es/ # Spanish translations (mirrors content/ folder structure) - pt-BR/ # Brazilian Portuguese translations -site/ # Deployable site (static assets + generated HTML) - es/ # Generated Spanish pages - pt-BR/ # Generated Portuguese pages -templates/ # HTML templates with {{…}} tokens for content + UI strings -html-generators/ # Build scripts, categories.properties, locales.properties -specs/ # Feature specifications (e.g., i18n-spec.md) -``` +### Removing or reordering a pattern -## JSON Snippet Schema - -Each `content/category/slug.json` file has this structure: - -```json -{ - "id": 1, - "slug": "type-inference-with-var", - "title": "Type inference with var", - "category": "language", - "difficulty": "beginner|intermediate|advanced", - "jdkVersion": "10", - "oldLabel": "Java 8", - "modernLabel": "Java 10+", - "oldApproach": "Explicit Types", - "modernApproach": "var keyword", - "oldCode": "// old way...", - "modernCode": "// modern way...", - "summary": "One-line description.", - "explanation": "How it works paragraph.", - "whyModernWins": [ - { "icon": "⚡", "title": "Short title", "desc": "One sentence." }, - { "icon": "👁", "title": "Short title", "desc": "One sentence." }, - { "icon": "🔒", "title": "Short title", "desc": "One sentence." } - ], - "support": { - "state": "available", - "description": "Widely available since JDK 10 (March 2018)" - }, - "prev": "category/slug-of-previous", - "next": "category/slug-of-next", - "related": [ - "category/slug-1", - "category/slug-2", - "category/slug-3" - ], - "docs": [ - { "title": "Javadoc or Guide Title", "href": "https://docs.oracle.com/..." } - ] -} -``` +Update `prev`/`next` in adjacent patterns. Search for the slug in other patterns' `related` arrays and replace with an appropriate alternative. + +## Internationalization + +Full spec: `specs/i18n/i18n-spec.md`. Key rules: + +- All locales (including English) go through the same build pipeline. +- UI strings: `translations/strings/{locale}.yaml`. Missing keys fall back to English with a build-time warning. +- Content translations contain **only** translatable fields: `title`, `summary`, `explanation`, `oldApproach`, `modernApproach`, `whyModernWins`, `support.description`. Code, slugs, navigation, and docs are never translated. +- `oldCode`/`modernCode` in translation files are **always overwritten** with English values at build time to prevent hallucinated code. +- Locale registry: `html-generators/locales.properties` (format: `locale=Display Name`). +- When adding a new UI string key, add it to `en.yaml` first, then to all other locale files. The generator warns on missing keys but doesn't fail. +- YAML colons in string values must be quoted (Jackson parser is stricter than PyYAML). + +## Proof Files -### Key Rules - -- `slug` must match the filename (without `.json`) -- `category` must match the parent folder name -- `whyModernWins` must have exactly **3** entries -- `related` must have exactly **3** entries (as `category/slug` paths) -- `docs` must have at least **1** entry linking to Javadoc or Oracle documentation -- `prev`/`next` are `category/slug` paths or `null` for first/last -- Code in `oldCode`/`modernCode` uses `\n` for newlines - -## Category Display Names - -Categories and their display names are defined in `html-generators/categories.properties`: - -| ID | Display | -|----|---------| -| `language` | Language | -| `collections` | Collections | -| `strings` | Strings | -| `streams` | Streams | -| `concurrency` | Concurrency | -| `io` | I/O | -| `errors` | Errors | -| `datetime` | Date/Time | -| `security` | Security | -| `tooling` | Tooling | -| `enterprise` | Enterprise | - -## Adding a New Pattern - -1. Create `content/category/new-slug.json` with all required fields -2. Update `prev`/`next` in the adjacent patterns' JSON files -3. Run `jbang html-generators/generate.java` -4. (Optional) Create translated content files under `translations/content/{locale}/category/new-slug.json` with only translatable fields — or let the AI translation workflow handle it - -## Internationalization (i18n) - -The site supports multiple languages. See `specs/i18n/i18n-spec.md` for the full specification. - -### Key Concepts - -- **UI strings:** Hard-coded template text (labels, nav, footer) is extracted into `translations/strings/{locale}.yaml`. Templates use `{{dotted.key}}` tokens (e.g., `{{nav.allPatterns}}`, `{{sections.codeComparison}}`). Missing keys fall back to the English value with a build-time warning. -- **Content translations:** Translated pattern files under `translations/content/{locale}/` contain **only** translatable fields (`title`, `summary`, `explanation`, `oldApproach`, `modernApproach`, `whyModernWins`, `support.description`). All other fields (`oldCode`, `modernCode`, `slug`, `id`, `prev`, `next`, `related`, `docs`, etc.) are always taken from the English source. -- **Locale registry:** `html-generators/locales.properties` lists supported locales (format: `locale=Display name`). The first entry is the default. -- **English is a first-class locale:** All locales — including English — go through the same build pipeline. -- **Fallback:** If a pattern has no translation file for a locale, the English content is used and an "untranslated" banner is shown. - -### Supported Locales - -Defined in `html-generators/locales.properties`: - -| Locale | Display Name | -|--------|-------------| -| `en` | English | -| `es` | Español | -| `pt-BR` | Português (Brasil) | - -### Content Translation File Example - -Translation files contain **only** translatable fields — no structural data: - -```json -// translations/content/pt-BR/language/type-inference-with-var.json -{ - "title": "Inferência de tipo com var", - "oldApproach": "Tipos explícitos", - "modernApproach": "Palavra-chave var", - "summary": "Use var para deixar o compilador inferir o tipo local.", - "explanation": "...", - "whyModernWins": [ - { "icon": "⚡", "title": "Menos ruído", "desc": "..." }, - { "icon": "👁", "title": "Mais legível", "desc": "..." }, - { "icon": "🔒", "title": "Seguro", "desc": "..." } - ], - "support": { - "description": "Amplamente disponível desde o JDK 10 (março de 2018)" - } +Each pattern has a corresponding proof file: `proof/{category}/{PascalCaseSlug}.java`. + +```java +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25+ + +/// Proof: slug-name +/// Source: content/category/slug-name.yaml +void main() { + // modern code only — no old code, no assertions } ``` -## Local Development +Uses Java 25 implicit classes (`void main()`, not `static void main`). Add minimal scaffolding (imports, dummy variables) to make the modern code compile. -```bash -jbang html-generators/generate.java # Build HTML pages + snippets.json -jwebserver -d site -p 8090 # Serve locally -``` +## Key Conventions + +- **Vanilla JS only** — `site/app.js` uses no frameworks or build tools. +- **Category display names** are defined in `html-generators/categories.properties`, not hardcoded. +- **JDK filter ranges** in `app.js` map LTS versions to ranges: `11→[9-11]`, `17→[12-17]`, `21→[18-21]`, `25→[22-25]`. +- **JetBrains Mono ligatures** are disabled on `.code-text` elements to prevent operators like `->` from rendering as special characters. +- **Dark theme** uses CSS custom properties (`--modern-bg`, `--old-bg`). Theme state is in `localStorage.theme` and `data-theme` on ``. +- **RTL support** — Arabic (`ar`) locale sets `dir="rtl"` on the page. +- When both old and modern approaches are from the same JDK version, use descriptive labels (e.g., "Full syntax" / "Compact") instead of version numbers. diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 87b8507..d06a670 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -9,10 +9,7 @@ permissions: jobs: benchmark: - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -28,8 +25,7 @@ jobs: with: python-version: '3.13' - - name: Install system dependencies (Ubuntu) - if: runner.os == 'Linux' + - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libcairo2-dev - name: Install Python dependencies @@ -44,43 +40,41 @@ jobs: OG_AOT="html-generators/generateog.aot" STEADY_RUNS=5 - snippet_count=$(find content -name '*.json' | wc -l | tr -d ' ') + 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="${{ matrix.os }}" + os_name="ubuntu-latest" - # Timing helper using bash TIMEFORMAT + # Nanosecond timestamp helper + _now() { python3 -c "import time; print(int(time.time()*1e9))"; } + + # Timing helper — returns seconds or "FAIL" measure() { - TIMEFORMAT='%R' - local t ec - t=$( { time "$@" > /dev/null 2>&1; ec=$?; } 2>&1 ) - if [[ ${ec:-1} -ne 0 ]]; then - # Re-check: the subshell exit code isn't always captured - if ! "$@" > /dev/null 2>&1; then - echo "FAIL" - return 1 - fi + local start end + start=$(_now) + if "$@" > /dev/null 2>&1; then + end=$(_now) + awk "BEGIN {printf \"%.2f\", ($end - $start) / 1000000000}" + else + echo "FAIL" fi - echo "$t" } avg_runs() { local n="$1"; shift - local sum=0 failures=0 + local sum=0 success=0 for ((i = 1; i <= n; i++)); do local t - t=$(measure "$@") || true - if [[ "$t" == "FAIL" ]]; then - ((failures++)) || true - continue + t=$(measure "$@") + if [[ "$t" != "FAIL" ]]; then + sum=$(awk "BEGIN {print $sum + $t}") + success=$((success + 1)) fi - sum=$(awk "BEGIN {print $sum + $t}") done - local success=$((n - failures)) if [[ $success -eq 0 ]]; then echo "FAIL" - return 0 + else + awk "BEGIN {printf \"%.2f\", $sum / $success}" fi - awk "BEGIN {printf \"%.2f\", $sum / $success}" } echo "Running benchmark on $os_name (Java $java_ver, $snippet_count snippets)..." @@ -103,14 +97,14 @@ jobs: # 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 -jar "$JAR") - AOT_STEADY=$(avg_runs $STEADY_RUNS java -XX:AOTCache="$AOT" -jar "$JAR") + 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 -jar "$OG_JAR") - OG_AOT_STEADY=$(avg_runs $STEADY_RUNS java -XX:AOTCache="$OG_AOT" -jar "$OG_JAR") + 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 { @@ -164,10 +158,7 @@ jobs: # Python and JBang start with zero caches, just like real CI. # --------------------------------------------------------------------------- build-jar: - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -188,7 +179,7 @@ jobs: - name: Upload JAR and AOT uses: actions/upload-artifact@v4 with: - name: generator-${{ matrix.os }} + name: generator path: | html-generators/generate.jar html-generators/generate.aot @@ -197,10 +188,7 @@ jobs: ci-cold-start: needs: build-jar - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -214,8 +202,7 @@ jobs: with: python-version: '3.13' - - name: Install system dependencies (Ubuntu) - if: runner.os == 'Linux' + - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libcairo2-dev - name: Install Python dependencies @@ -224,40 +211,40 @@ jobs: - name: Download JAR and AOT uses: actions/download-artifact@v4 with: - name: generator-${{ matrix.os }} + name: generator path: html-generators - name: CI cold-start benchmark shell: bash run: | - os_name="${{ matrix.os }}" + os_name="ubuntu-latest" java_ver=$(java -version 2>&1 | head -1 | sed 's/.*"\(.*\)".*/\1/') - snippet_count=$(find content -name '*.json' | wc -l | tr -d ' ') + 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() { - TIMEFORMAT='%R' - local t ec - t=$( { time "$@" > /dev/null 2>&1; ec=$?; } 2>&1 ) - if [[ ${ec:-1} -ne 0 ]]; then - if ! "$@" > /dev/null 2>&1; then - echo "FAIL" - return 1 - fi + local start end + start=$(_now) + if "$@" > /dev/null 2>&1; then + end=$(_now) + awk "BEGIN {printf \"%.2f\", ($end - $start) / 1000000000}" + else + echo "FAIL" fi - echo "$t" } # Everything is cold: no __pycache__, no JBang cache, fresh JVM # HTML generator PY_CI=$(measure python3 html-generators/generate.py) - JAR_CI=$(measure java -jar html-generators/generate.jar) - AOT_CI=$(measure java -XX:AOTCache=html-generators/generate.aot -jar html-generators/generate.jar) + 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 -jar html-generators/generateog.jar) - OG_AOT_CI=$(measure java -XX:AOTCache=html-generators/generateog.aot -jar html-generators/generateog.jar) + 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\`" diff --git a/.github/workflows/check-new-jdk.lock.yml b/.github/workflows/check-new-jdk.lock.yml deleted file mode 100644 index b40ee5f..0000000 --- a/.github/workflows/check-new-jdk.lock.yml +++ /dev/null @@ -1,1130 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.50.4). DO NOT EDIT. -# -# To update this file, edit the corresponding .md file and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# Checks for new OpenJDK releases and proposes new java.evolved snippets covering newly finalized language features and APIs. -# -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"b2476905fe4edf0c55a76a2238afc4ea0eef32b3eedd8974de20e92bdf27bd04","compiler_version":"v0.50.4"} - -name: "Check for New OpenJDK Release and Propose New Snippets" -"on": - schedule: - - cron: "0 12 15-21 3,9 5" - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}" - -run-name: "Check for New OpenJDK Release and Propose New Snippets" - -jobs: - activation: - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@90ebf8057e8e005103b8d123732d2c64c30e9b27 # v0.50.4 - with: - destination: /opt/gh-aw/actions - - name: Validate context variables - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/validate_context_variables.cjs'); - await main(); - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github - .agents - fetch-depth: 1 - persist-credentials: false - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "check-new-jdk.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - { - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" - cat "/opt/gh-aw/prompts/markdown.md" - cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' - - Tools: create_pull_request, missing_tool, missing_data - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/safe_outputs_create_pull_request.md" - cat << 'GH_AW_PROMPT_EOF' - - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' - {{#runtime-import check-new-jdk.md}} - GH_AW_PROMPT_EOF - } > "$GH_AW_PROMPT" - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload prompt artifact - if: success() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - issues: read - pull-requests: read - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_AW_ASSETS_ALLOWED_EXTS: "" - GH_AW_ASSETS_BRANCH: "" - GH_AW_ASSETS_MAX_SIZE_KB: 0 - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_WORKFLOW_ID_SANITIZED: checknewjdk - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} - has_patch: ${{ steps.collect_output.outputs.has_patch }} - model: ${{ steps.generate_aw_info.outputs.model }} - output: ${{ steps.collect_output.outputs.output }} - output_types: ${{ steps.collect_output.outputs.output_types }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@90ebf8057e8e005103b8d123732d2c64c30e9b27 # v0.50.4 - with: - destination: /opt/gh-aw/actions - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - github.event.pull_request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "copilot", - engine_name: "GitHub Copilot CLI", - model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", - version: "", - agent_version: "0.0.417", - cli_version: "v0.50.4", - workflow_name: "Check for New OpenJDK Release and Propose New Snippets", - experimental: false, - supports_tools_allowlist: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - allowed_domains: ["defaults"], - firewall_enabled: true, - awf_version: "v0.23.0", - awmg_version: "v0.1.5", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() - }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.417 - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 - - name: Determine automatic lockdown mode for GitHub MCP Server - id: determine-automatic-lockdown - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - with: - script: | - const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); - await determineAutomaticLockdown(github, context, core); - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.5 ghcr.io/github/github-mcp-server:v0.31.0 node:lts-alpine - - name: Write Safe Outputs Config - run: | - mkdir -p /opt/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_pull_request":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF - cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' - [ - { - "description": "Create a new GitHub pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. For code review comments on an existing PR, use create_pull_request_review_comment instead. CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[new-jdk] \". Labels [enhancement new-jdk-release] will be automatically added.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "Detailed PR description in Markdown. Include what changes were made, why, testing notes, and any breaking changes. Do NOT repeat the title as a heading.", - "type": "string" - }, - "branch": { - "description": "Source branch name containing the changes. If omitted, uses the current working branch.", - "type": "string" - }, - "draft": { - "description": "Whether to create the PR as a draft. Draft PRs cannot be merged until marked as ready for review. Use mark_pull_request_as_ready_for_review to convert a draft PR. Default: true.", - "type": "boolean" - }, - "labels": { - "description": "Labels to categorize the PR (e.g., 'enhancement', 'bugfix'). Labels must exist in the repository.", - "items": { - "type": "string" - }, - "type": "array" - }, - "title": { - "description": "Concise PR title describing the changes. Follow repository conventions (e.g., conventional commits). The title appears as the main heading.", - "type": "string" - } - }, - "required": [ - "title", - "body" - ], - "type": "object" - }, - "name": "create_pull_request" - }, - { - "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "reason": { - "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", - "type": "string" - }, - "tool": { - "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "name": "missing_tool" - }, - { - "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "message": { - "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - }, - "name": "noop" - }, - { - "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "context": { - "description": "Additional context about the missing data or where it should come from (max 256 characters).", - "type": "string" - }, - "data_type": { - "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", - "type": "string" - }, - "reason": { - "description": "Explanation of why this data is needed to complete the task (max 256 characters).", - "type": "string" - } - }, - "required": [], - "type": "object" - }, - "name": "missing_data" - } - ] - GH_AW_SAFE_OUTPUTS_TOOLS_EOF - cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_pull_request": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "branch": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "draft": { - "type": "boolean" - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 - } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - } - } - } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash /opt/gh-aw/actions/start_safe_outputs_server.sh - - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} - GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export DEBUG="*" - - export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.5' - - mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v0.31.0", - "env": { - "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "pull_requests,issues,repos" - } - }, - "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); - await generateWorkflowOverview(core); - - name: Download prompt artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - timeout-minutes: 30 - run: | - set -o pipefail - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json - GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Copy Copilot session state files to logs - if: always() - continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' - SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload Safe Outputs - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: safe-output - path: ${{ env.GH_AW_SAFE_OUTPUTS }} - if-no-files-found: warn - - name: Ingest agent output - id: collect_output - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); - await main(); - - name: Upload sanitized agent output - if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent-output - path: ${{ env.GH_AW_AGENT_OUTPUT }} - if-no-files-found: warn - - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent_outputs - path: | - /tmp/gh-aw/sandbox/agent/logs/ - /tmp/gh-aw/redacted-urls.log - if-no-files-found: ignore - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - /tmp/gh-aw/aw-*.patch - if-no-files-found: ignore - # --- Threat Detection (inline) --- - - name: Check if detection needed - id: detection_guard - if: always() - env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - run: | - if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then - echo "run_detection=true" >> "$GITHUB_OUTPUT" - echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" - else - echo "run_detection=false" >> "$GITHUB_OUTPUT" - echo "Detection skipped: no agent outputs or patches to analyze" - fi - - name: Clear MCP configuration for detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - rm -f /tmp/gh-aw/mcp-config/mcp-servers.json - rm -f /home/runner/.copilot/mcp-config.json - rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - - name: Prepare threat detection files - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - - name: Setup threat detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "Check for New OpenJDK Release and Propose New Snippets" - WORKFLOW_DESCRIPTION: "Checks for new OpenJDK releases and proposes new java.evolved snippets covering newly finalized language features and APIs." - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Execute GitHub Copilot CLI - if: always() && steps.detection_guard.outputs.run_detection == 'true' - id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: threat-detection.log - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - name: Set detection conclusion - id: detection_conclusion - if: always() - env: - RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi - - conclusion: - needs: - - activation - - agent - - safe_outputs - if: (always()) && (needs.agent.result != 'skipped') - runs-on: ubuntu-slim - permissions: - contents: write - issues: write - pull-requests: write - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@90ebf8057e8e005103b8d123732d2c64c30e9b27 # v0.50.4 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "Check for New OpenJDK Release and Propose New Snippets" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Check for New OpenJDK Release and Propose New Snippets" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Check for New OpenJDK Release and Propose New Snippets" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "check-new-jdk" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} - GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} - GH_AW_GROUP_REPORTS: "false" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Check for New OpenJDK Release and Propose New Snippets" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); - await main(); - - name: Handle Create Pull Request Error - id: handle_create_pr_error - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Check for New OpenJDK Release and Propose New Snippets" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_create_pr_error.cjs'); - await main(); - - safe_outputs: - needs: - - activation - - agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') - runs-on: ubuntu-slim - permissions: - contents: write - issues: write - pull-requests: write - timeout-minutes: 15 - env: - GH_AW_ENGINE_ID: "copilot" - GH_AW_WORKFLOW_ID: "check-new-jdk" - GH_AW_WORKFLOW_NAME: "Check for New OpenJDK Release and Propose New Snippets" - outputs: - code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} - code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} - create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} - create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} - process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@90ebf8057e8e005103b8d123732d2c64c30e9b27 # v0.50.4 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Download patch artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent-artifacts - path: /tmp/gh-aw/ - - name: Checkout repository - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref || github.ref_name }} - token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - persist-credentials: false - fetch-depth: 1 - - name: Configure Git credentials - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Process Safe Outputs - id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"base_branch\":\"${{ github.base_ref || github.ref_name }}\",\"labels\":[\"enhancement\",\"new-jdk-release\"],\"max\":1,\"max_patch_size\":1024,\"title_prefix\":\"[new-jdk] \"},\"missing_data\":{},\"missing_tool\":{}}" - GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); - await main(); - - name: Upload safe output items manifest - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn - diff --git a/.github/workflows/check-new-jdk.md b/.github/workflows/check-new-jdk.md deleted file mode 100644 index 5bf4c71..0000000 --- a/.github/workflows/check-new-jdk.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -on: - schedule: - - cron: '0 12 15-21 3,9 5' # Third Friday of March and September at noon UTC - workflow_dispatch: -description: > - Checks for new OpenJDK releases and proposes new java.evolved snippets - covering newly finalized language features and APIs. -strict: false -permissions: - contents: read - pull-requests: read - issues: read -network: - allowed: - - defaults -tools: - web-fetch: - edit: - bash: true - github: - toolsets: [pull_requests, issues, repos] -safe-outputs: - create-pull-request: - title-prefix: "[new-jdk] " - labels: [enhancement, new-jdk-release] -timeout-minutes: 30 ---- - -# Check for New OpenJDK Release and Propose New Snippets - -You are a Java expert maintaining the **java.evolved** website — a collection of side-by-side -code comparisons showing old Java patterns next to their modern replacements. - -## Your Task - -1. **Check for new OpenJDK releases.** - - Fetch the OpenJDK project page at `https://openjdk.org/projects/jdk/` and identify the - latest GA (General Availability) JDK release. - - Compare it against the site's current coverage. Read the existing JSON snippet files in - the category subfolders (e.g., `language/*.json`, `concurrency/*.json`) to see which - JDK versions are already covered. - - If the latest GA release is already fully covered, stop and report "No new JDK release to cover." - -2. **Research new features.** - - Go to `https://openjdk.org/projects/jdk/{version}/` for the new release. - - Identify all JEPs that are **finalized** (not preview, not incubator, not experimental). - - Focus on **language features** and **API additions** that a typical Java developer would use - in application code. Skip internal/VM-only JEPs (GC changes, ports, JFR internals, etc.) - unless they have a clear developer-facing usage pattern. - - Also note any features that graduated from preview to final in this release. - -3. **Create new snippet JSON files.** - - Each snippet is an individual JSON file at `{category}/{slug}.json`. - - Use an existing JSON file (e.g., `language/type-inference-with-var.json`) as a reference - for the schema. Each snippet needs: - - `id`: next sequential integer - - `slug`: kebab-case URL slug (must match the filename without `.json`) - - `title`: human-readable title - - `category`: one of language, collections, strings, streams, concurrency, io, errors, datetime, security, tooling (must match the parent folder) - - `difficulty`: beginner, intermediate, or advanced - - `jdkVersion`: the JDK version where this became final (non-preview) - - `oldLabel` / `modernLabel`: e.g., "Java 8" / "Java 26+" - - `oldApproach` / `modernApproach`: short description of each approach - - `oldCode` / `modernCode`: complete, compilable code snippets (concise, max ~12 lines each) - - `summary`: one-sentence summary - - `explanation`: 2-3 sentence explanation of why the modern approach is better - - `whyModernWins`: array of exactly 3 objects with `icon`, `title`, `desc` - - `support`: version info string, e.g., "Finalized in JDK 26 (JEP NNN, Month Year)." - - `prev`: `category/slug` of the previous pattern, or `null` if first - - `next`: `category/slug` of the next pattern, or `null` if last - - `related`: array of exactly 3 `category/slug` strings for related patterns - -4. **Update existing files.** - - Update the `next` field of the last existing snippet's JSON file to point to the first - new snippet, and set the new snippet's `prev` accordingly. Chain all new snippets together. - - Add a preview card for each new snippet to `site/index.html` inside the `#tipsGrid` div. - - The snippet count in `site/index.html` uses `{{snippetCount}}` placeholders — it is updated - automatically by the generator. - - Do **NOT** edit `site/data/snippets.json` or any HTML files in `site/` category subfolders — these are - generated by `html-generators/generate.java` and must not be modified directly. - -5. **Verify the build.** - - Run `java -jar html-generators/generate.jar` to regenerate all HTML pages and `site/data/snippets.json`. - - Confirm the new pages were generated successfully. - -6. **Create a pull request.** - - The PR title should be: `[new-jdk] Add snippets for JDK {version} features` - - The PR body should list each new snippet with its title and a one-line summary. - - Mention which JEPs are covered and link to the OpenJDK release page. - -## Important Rules - -- Only include features that are **final** (non-preview) in the new JDK release. -- Label preview features as preview if you choose to include them, with "(Preview)" in the modernLabel. -- All content goes in `content/{category}/{slug}.json` files — never edit generated HTML or `site/data/snippets.json`. -- Run `java -jar html-generators/generate.jar` after making changes to verify the build. -- Do not modify existing snippet JSON files unless a feature graduated from preview to final. -- If a previously preview feature is now final, update its `modernLabel` and `support` text - to remove the "(Preview)" label in its JSON file. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bae47a5..4b621a3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,6 +9,7 @@ on: - 'templates/**' - 'site/**' - 'html-generators/generateog.java' + - 'html-generators/og/**' workflow_run: workflows: ['Build Generator JARs'] types: [completed] @@ -36,13 +37,14 @@ jobs: - name: Detect changed paths id: changes run: | + # OG images are gitignored and must always be regenerated + echo "og=true" >> "$GITHUB_OUTPUT" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then echo "generate=true" >> "$GITHUB_OUTPUT" - echo "og=true" >> "$GITHUB_OUTPUT" elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then - # Generator JAR was rebuilt — regenerate HTML, skip OG + # Generator JAR was rebuilt — regenerate HTML echo "generate=true" >> "$GITHUB_OUTPUT" - echo "og=false" >> "$GITHUB_OUTPUT" else # Push event — check which files changed CHANGED=$(git diff --name-only HEAD~1 HEAD) @@ -50,18 +52,12 @@ jobs: echo "$CHANGED" NEEDS_GENERATE=false - NEEDS_OG=false if echo "$CHANGED" | grep -qE '^(content/|translations/|templates/)'; then NEEDS_GENERATE=true - NEEDS_OG=true - fi - if echo "$CHANGED" | grep -qE '^html-generators/generateog\.java$'; then - NEEDS_OG=true fi echo "generate=$NEEDS_GENERATE" >> "$GITHUB_OUTPUT" - echo "og=$NEEDS_OG" >> "$GITHUB_OUTPUT" fi echo "Summary: generate=${{ steps.changes.outputs.generate || 'pending' }}, og=${{ steps.changes.outputs.og || 'pending' }}" @@ -84,7 +80,7 @@ jobs: - name: Generate HTML with cached JAR + AOT if: steps.changes.outputs.generate == 'true' && steps.cache-restore.outputs.cache-hit == 'true' - run: java -XX:AOTCache=html-generators/generate.aot -jar html-generators/generate.jar + run: java -XX:Tier4CompileThreshold=100 -XX:AOTCache=html-generators/generate.aot -jar html-generators/generate.jar - name: Setup JBang (cache miss) if: steps.changes.outputs.generate == 'true' && steps.cache-restore.outputs.cache-hit != 'true' @@ -107,7 +103,7 @@ jobs: - name: Generate OG cards with cached JAR + AOT if: steps.changes.outputs.og == 'true' && steps.cache-restore-og.outputs.cache-hit == 'true' - run: java -XX:AOTCache=html-generators/generateog.aot -jar html-generators/generateog.jar + run: java -XX:Tier4CompileThreshold=100 -XX:AOTCache=html-generators/generateog.aot -jar html-generators/generateog.jar - name: Generate OG cards with JBang (cache miss) if: steps.changes.outputs.og == 'true' && steps.cache-restore-og.outputs.cache-hit != 'true' diff --git a/.github/workflows/social-post.yml b/.github/workflows/social-post.yml new file mode 100644 index 0000000..d8af61c --- /dev/null +++ b/.github/workflows/social-post.yml @@ -0,0 +1,44 @@ +name: Twice-Weekly Social Post + +on: + schedule: + - cron: '0 14 * * 1,4' # Every Monday and Thursday at 14:00 UTC (10 AM ET) + workflow_dispatch: # Manual trigger + +permissions: + contents: write + +concurrency: + group: social-post + cancel-in-progress: false + +jobs: + post: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' + + - uses: jbangdev/setup-jbang@main + + - name: Post to Twitter + env: + TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_APP_CONSUMER_KEY }} + TWITTER_CONSUMER_KEY_SECRET: ${{ secrets.TWITTER_APP_SECRET_KEY }} + TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} + TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + run: jbang html-generators/socialpost.java + + - name: Commit updated state + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add social/state.yaml social/queue.txt social/tweets.yaml + git diff --cached --quiet && exit 0 + git commit -m "chore: update social post state [skip ci]" + git pull --rebase + git push diff --git a/README.md b/README.md index bca85d4..99a5b0c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ The site supports 11 languages: English, Deutsch, Español, Português (Brasil), ### Prerequisites - **Java 25+** (e.g. [Temurin](https://adoptium.net/)) +- **JBang** ([Jbang](https://www.jbang.dev/)) ### Generate and serve @@ -68,8 +69,8 @@ The site supports 11 languages: English, Deutsch, Español, Português (Brasil), # Generate all HTML pages and data/snippets.json into site/ jbang html-generators/generate.java -# Serve locally -jwebserver -b 0.0.0.0 -d site -p 8090 +# Serve locally need to replace path with absolute path to site folder +jwebserver -b 0.0.0.0 -d path/to/site -p 8090 # Open http://localhost:8090 ``` diff --git a/blogs/replacing-batik-with-graphics2d.md b/blogs/replacing-batik-with-graphics2d.md new file mode 100644 index 0000000..0058ad4 --- /dev/null +++ b/blogs/replacing-batik-with-graphics2d.md @@ -0,0 +1,241 @@ +# Replacing Apache Batik with Graphics2D: How We Made Our Java OG Card Generator Faster Than Python + +*March 2026 · [javaevolved.github.io](https://javaevolved.github.io)* + +--- + +[Java Evolved](https://javaevolved.github.io) is a static site showcasing 112 modern Java patterns across 11 categories — each with a side-by-side "old vs modern" code comparison. For every pattern, we generate an Open Graph card: a 1200×630 PNG image used when links are shared on social media. + +This is the story of how we replaced Apache Batik with plain `Graphics2D`, split a monolithic script into modules using JBang, and tuned the JVM to squeeze out every last bit of performance — ending up faster than Python. + +## The starting point: Batik + +Our OG card generator was a single 400-line Java file (`generateog.java`) run via [JBang](https://www.jbang.dev). It built an SVG string for each card, then used Apache Batik's `PNGTranscoder` to rasterize it to PNG: + +```java +//DEPS org.apache.xmlgraphics:batik-transcoder:1.18 +//DEPS org.apache.xmlgraphics:batik-codec:1.18 + +static void svgToPng(String svgContent, Path pngPath) throws Exception { + var input = new TranscoderInput(new StringReader(svgContent)); + try (var out = new BufferedOutputStream(Files.newOutputStream(pngPath))) { + var transcoder = new PNGTranscoder(); + transcoder.addTranscodingHint(PNGTranscoder.KEY_WIDTH, (float) W * 2); + transcoder.addTranscodingHint(PNGTranscoder.KEY_HEIGHT, (float) H * 2); + transcoder.transcode(input, new TranscoderOutput(out)); + } +} +``` + +This worked, but Batik is a heavyweight library. It brings in the full AWT/Swing graphics pipeline, XML parsers, and codec libraries. The fat JAR was over 10 MB. And it was **slower than our equivalent Python script using cairosvg** — a thin wrapper around the native Cairo C library. + +We had an irony on our hands: a site about modern Java patterns had a Java tool that couldn't outperform Python. + +## The insight: we don't need SVG→PNG + +Our OG cards are simple layouts — rounded rectangles, text, solid fills. We were building an SVG string by hand, then asking Batik to parse it back into a graphics model and rasterize it. That's a round trip through XML parsing for geometry we already knew. + +Java's `Graphics2D` API can draw all of this directly to a `BufferedImage`: + +```java +var img = new BufferedImage(W * SCALE, H * SCALE, BufferedImage.TYPE_INT_RGB); +var g = img.createGraphics(); +g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); +g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); +g.scale(SCALE, SCALE); + +// Draw directly — no SVG intermediary +drawCard(g, snippet); + +g.dispose(); +ImageIO.write(img, "PNG", outputPath.toFile()); +``` + +No SVG parsing. No Batik. No external dependencies for PNG output — just the JDK. + +We still generate SVG files (for the web), but PNG rendering is now pure `Graphics2D`. + +## The refactoring: JBang multi-source + +The original 400-line monolith mixed colors, dimensions, content loading, syntax highlighting, SVG generation, PNG conversion, and font management all in one implicit class. We split it into 7 focused source files using JBang's `//SOURCES` directive: + +```java +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25 +//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3 +//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3 +//SOURCES og/Palette.java +//SOURCES og/Layout.java +//SOURCES og/ContentLoader.java +//SOURCES og/SyntaxHighlighter.java +//SOURCES og/SvgRenderer.java +//SOURCES og/PngRenderer.java +//SOURCES og/FontManager.java +``` + +Each file lives in the `og` package under `html-generators/og/`: + +| File | Responsibility | +|------|---------------| +| `Palette.java` | Color constants (hex strings + `Color.decode()` helper) | +| `Layout.java` | Dimensions, font sizing, line fitting | +| `ContentLoader.java` | JSON/YAML parsing, `Snippet` record | +| `SyntaxHighlighter.java` | Java tokenizer → `List` (shared by SVG + PNG) | +| `SvgRenderer.java` | SVG string generation | +| `PngRenderer.java` | Direct `Graphics2D` PNG rendering | +| `FontManager.java` | Font downloading and registration | + +The `SyntaxHighlighter` is the key shared abstraction. Instead of producing SVG `` fragments directly, it returns a `List` where each token has text and an optional color: + +```java +public record Token(String text, String color) {} +``` + +The SVG renderer converts tokens to `` elements. The PNG renderer draws them with `Graphics2D`: + +```java +static void drawTokens(Graphics2D g, List tokens, int x, int y, + Font codeFont, FontRenderContext frc) { + float curX = x; + for (var token : tokens) { + g.setColor(token.color() != null + ? Palette.color(token.color()) + : Palette.color(Palette.SYN_DEFAULT)); + g.drawString(token.text(), curX, y); + curX += (float) codeFont.getStringBounds(token.text(), frc).getWidth(); + } +} +``` + +## Font loading: a subtle gotcha + +Our first `Graphics2D` attempt rendered text with the wrong fonts. The labels showed □ rectangles instead of ✗ and ✓ symbols. + +The problem: Java's `Font.createFont()` registers all physical fonts with `style=0` (PLAIN), regardless of their actual weight. When you write `new Font("Inter", Font.BOLD, 24)`, Java looks for a font in the "Inter" family with `BOLD` style — but the registered "Inter Bold" has `style=0`. Java falls back to algorithmic bolding of the Regular weight, or worse, to a system font that lacks the Unicode characters you need. + +The fix: load fonts directly from the `.ttf` files: + +```java +public static Font getFont(String filename, float size) { + var path = FONT_CACHE.resolve(filename); + return Font.createFont(Font.TRUETYPE_FONT, path.toFile()).deriveFont(size); +} +``` + +Then use exact physical fonts: + +```java +var titleFont = FontManager.getFont("Inter-Bold.ttf", 24f); +var labelFont = FontManager.getFont("Inter-SemiBold.ttf", 11f); +var codeFont = FontManager.getFont("JetBrainsMono-Regular.ttf", (float) fontSize); +``` + +## GC profiling: humongous allocations + +Running with `-Xlog:gc:stderr` revealed the OG generator triggered **34 GC events** with repeated "G1 Humongous Allocation" warnings: + +``` +GC(5) Pause Young (Concurrent Start) (G1 Humongous Allocation) 238M->138M(516M) +GC(7) Pause Young (Concurrent Start) (G1 Humongous Allocation) 234M->90M(516M) +``` + +Each `BufferedImage` at 2× resolution is 2400×1260×4 bytes ≈ **12 MB** — larger than half the default G1 region size. G1 treats these as "humongous" objects requiring special allocation paths. + +The fix was trivially simple: **reuse a single `BufferedImage`** across all 112 cards. + +```java +// Allocated once, reused for every card +static final BufferedImage SHARED_IMG = + new BufferedImage(W * SCALE, H * SCALE, BufferedImage.TYPE_INT_RGB); +``` + +Each card clears the image with a white fill before drawing. Result: **34 GCs → 1 GC**. + +## JIT tuning: lowering the C2 threshold + +With only 112 iterations, the JVM's C2 compiler (which normally triggers after ~10,000 invocations) never kicks in. The hot methods — tokenization, font metrics, `drawString` — run in C1-compiled (or interpreted) code. + +Lowering the C2 threshold to 100 invocations lets the optimizing compiler engage early: + +``` +-XX:Tier4CompileThreshold=100 +``` + +| Threshold | Time (112 cards) | Delta | +|-----------|-----------------|-------| +| Default (~10K) | 10.02s | baseline | +| **100** | **9.32s** | **−7%** | +| 1 | 9.78s | −2% | + +Threshold=100 is the sweet spot: C2 compiles the hot loop just in time for the bulk of the work. Threshold=1 wastes time compiling methods before they're warm. + +We added this flag to all `java -jar` execution calls in our CI workflows. It doesn't affect AOT training runs (which need default JIT behavior for proper class profiling). + +## The results + +### Local benchmarks (Apple M1 Max, 112 patterns) + +**OG Card Generator — Steady-State (avg of 5 runs):** + +| Method | Time | +|--------|------| +| **Fat JAR (Graphics2D)** | **10.82s** | +| Fat JAR + AOT | 11.04s | +| JBang (from source) | 11.62s | +| Python (cairosvg) | 14.10s | + +**HTML Generator — Steady-State (avg of 5 runs):** + +| Method | Time | +|--------|------| +| **Fat JAR** | **7.47s** | +| Fat JAR + AOT | 8.00s | +| JBang | 11.63s | +| Python | 31.52s | + +### What we shipped + +| Before | After | +|--------|-------| +| Batik transcoder + codec deps | Zero rendering deps (pure JDK) | +| 10+ MB fat JAR | 2.6 MB fat JAR | +| 1 monolithic file (400 lines) | 7 modular source files | +| 34 GC events per run | 1 GC event per run | +| Slower than Python | **24% faster than Python** | + +### Why AOT cache doesn't help here + +You might notice the AOT cache (`-XX:AOTCache`) shows no improvement over plain JAR execution. That's expected: JEP 483 AOT cache pre-loads classes from a training run, eliminating class-loading overhead on subsequent runs. But for our generator, class loading is already fast (~0.5s). The bottleneck is `ImageIO.write()` — pure CPU-bound PNG compression — which no amount of class pre-loading can speed up. + +## What we learned + +1. **Don't SVG-to-PNG when you can draw directly.** If you control the layout, `Graphics2D` + `ImageIO` is simpler, faster, and dependency-free. + +2. **Java's `Font.createFont()` registers everything as style=0.** Load fonts from files with `deriveFont()` instead of relying on `new Font(name, style, size)`. + +3. **Reuse large objects.** A 12 MB `BufferedImage` per card is a humongous allocation in G1. One shared buffer, cleared each iteration, drops GC events from 34 to 1. + +4. **Lower `-XX:Tier4CompileThreshold` for short-lived CLI apps.** With only ~100 iterations, the C2 compiler needs a nudge to engage before the work is done. + +5. **Profile before tuning.** We tested Epsilon GC, Shenandoah, ZGC, Serial GC, various heap sizes, and G1 region sizes. None moved the needle. The bottleneck was always PNG compression — a CPU-bound operation that no GC or heap configuration can improve. + +6. **JBang `//SOURCES` makes multi-file Java scripts practical.** No build tool, no `pom.xml` — just list your source files and run. + +## Try it yourself + +```bash +# Generate all 112 OG cards +jbang html-generators/generateog.java + +# Generate a single card +jbang html-generators/generateog.java language/type-inference-with-var + +# Build JAR + AOT for fastest execution +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 +java -XX:Tier4CompileThreshold=100 -XX:AOTCache=html-generators/generateog.aot -jar html-generators/generateog.jar +``` + +The full source is at [github.com/javaevolved/javaevolved.github.io](https://github.com/javaevolved/javaevolved.github.io) under `html-generators/`. + +CI benchmark results: [Actions run #22563953466](https://github.com/javaevolved/javaevolved.github.io/actions/runs/22563953466). diff --git a/content/collections/copying-collections-immutably.yaml b/content/collections/copying-collections-immutably.yaml index 3dd73f2..851216f 100644 --- a/content/collections/copying-collections-immutably.yaml +++ b/content/collections/copying-collections-immutably.yaml @@ -29,8 +29,8 @@ whyModernWins: title: "One call" desc: "No manual ArrayList construction + wrapping." - icon: "🛡️" - title: "Defensive copy" - desc: "Changes to the original don't affect the copy." + title: "Any Collection" + desc: "Accepts any Collection as input—no intermediate ArrayList conversion needed." support: state: "available" description: "Widely available since JDK 10 (March 2018)" diff --git a/content/collections/sequenced-collections.yaml b/content/collections/sequenced-collections.yaml index 03b4026..09d2169 100644 --- a/content/collections/sequenced-collections.yaml +++ b/content/collections/sequenced-collections.yaml @@ -11,9 +11,9 @@ oldApproach: "Index Arithmetic" modernApproach: "getFirst/getLast" oldCode: |- // Get last element - var last = list.get(list.size() - 1); + Object last = list.get(list.size() - 1); // Get first - var first = list.get(0); + Object first = list.get(0); // Reverse iteration: manual modernCode: |- var last = list.getLast(); diff --git a/content/collections/stream-toarray-typed.yaml b/content/collections/stream-toarray-typed.yaml index 8c291f7..7fb7897 100644 --- a/content/collections/stream-toarray-typed.yaml +++ b/content/collections/stream-toarray-typed.yaml @@ -7,22 +7,26 @@ difficulty: "beginner" jdkVersion: "8" oldLabel: "Pre-Streams" modernLabel: "Java 8+" -oldApproach: "Manual Array Copy" +oldApproach: "Manual Filter + Copy" modernApproach: "toArray(generator)" oldCode: |- List list = getNames(); - String[] arr = new String[list.size()]; - for (int i = 0; i < list.size(); i++) { - arr[i] = list.get(i); + List filtered = new ArrayList<>(); + for (String n : list) { + if (n.length() > 3) { + filtered.add(n); + } } + String[] arr = filtered.toArray(new String[0]); modernCode: |- String[] arr = getNames().stream() .filter(n -> n.length() > 3) .toArray(String[]::new); -summary: "Convert streams to typed arrays with a method reference." -explanation: "The toArray(IntFunction) method creates a properly typed array from\ - \ a stream. The generator (String[]::new) tells the stream what type of array to\ - \ create." +summary: "Filter a collection and collect the results to a typed array using a single stream expression." +explanation: "When you need to filter a collection before converting it to a typed array,\ + \ streams let you chain the operations without an intermediate list. The toArray(IntFunction)\ + \ generator (String[]::new) creates the correctly typed array directly at the end\ + \ of the pipeline, eliminating the manual loop and temporary ArrayList." whyModernWins: - icon: "🎯" title: "Type-safe" @@ -32,7 +36,7 @@ whyModernWins: desc: "Works at the end of any stream pipeline." - icon: "📏" title: "Concise" - desc: "One expression replaces the manual loop." + desc: "No intermediate list — one expression replaces the manual loop and copy." support: state: "available" description: "Widely available since JDK 8 (March 2014)" diff --git a/content/language/call-c-from-java.yaml b/content/language/call-c-from-java.yaml new file mode 100644 index 0000000..de12286 --- /dev/null +++ b/content/language/call-c-from-java.yaml @@ -0,0 +1,82 @@ +--- +id: 113 +slug: "call-c-from-java" +title: "Calling out to C code from Java" +category: "language" +difficulty: "advanced" +jdkVersion: "22" +oldLabel: "Java 1.1+" +modernLabel: "Java 22+" +oldApproach: "JNI (Java Native Interface)" +modernApproach: "FFM (Foreign Function & Memory API)" +oldCode: |- + public class CallCFromJava { + static { System.loadLibrary("strlen-jni"); } + public static native long strlen(String s); + public static void main(String[] args) { + long ret = strlen("Bambi"); + System.out.println("Return value " + ret); // 5 + } + } + + // Run javac -h to generate the .h file, then write C: + // #include "CallCFromJava.h" + // #include + // JNIEXPORT jlong JNICALL Java_CallCFromJava_strlen( + // JNIEnv *env, jclass clazz, jstring str) { + // const char* s = (*env)->GetStringUTFChars(env, str, NULL); + // jlong len = (jlong) strlen(s); + // (*env)->ReleaseStringUTFChars(env, str, s); + // return len; + // } +modernCode: |- + void main() throws Throwable { + try (var arena = Arena.ofConfined()) { + // Use any system library directly — no C wrapper needed + var stdlib = Linker.nativeLinker().defaultLookup(); + var foreignFuncAddr = stdlib.find("strlen").orElseThrow(); + var strlenSig = FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS); + var strlenMethod = Linker.nativeLinker() .downcallHandle(foreignFuncAddr, strlenSig); + var ret = (long) strlenMethod.invokeExact(arena.allocateFrom("Bambi")); + System.out.println("Return value " + ret); // 5 + } + } + + // Your own C library needs no special Java annotations: + // long greet(char* name) { + // printf("Hello %s\n", name); + // return 0; + // } +summary: "FFM lets Java call C libraries directly, without JNI boilerplate or C-side Java knowledge." +explanation: "Java has two approaches for calling native C/C++ code: the traditional\ + \ JNI and the modern FFM API. With JNI, you declare a method as native, run javac -h\ + \ to generate a C header file, then implement the function using the cumbersome JNI\ + \ C API (JNIEnv, jstring, etc.). FFM, introduced as a standard API in Java 22,\ + \ eliminates all of that: C code is just plain C — no JNI conventions needed. This\ + \ makes it far easier to call existing C/C++ libraries without modification. The\ + \ Java side uses Arena for safe off-heap memory management and MethodHandle for the\ + \ downcall, ensuring both flexibility and safety." +whyModernWins: +- icon: "👁" + title: "C code stays plain C" + desc: "The C function requires no JNI annotations or JNIEnv boilerplate — any existing C library can be called as-is." +- icon: "⚡" + title: "More flexible" + desc: "Directly call most existing C/C++ libraries without writing adapter code or generating header files." +- icon: "🛠️" + title: "Easier workflow" + desc: "No need to stop, run javac -h, and implement the interface defined in the generated .h file." +support: + state: "available" + description: "Standardized in JDK 22 (March 2024); previously incubating since JDK 14" +prev: "language/compact-canonical-constructor" +next: "enterprise/servlet-vs-jaxrs" +related: +- "io/file-memory-mapping" +- "language/compact-source-files" +- "language/unnamed-variables" +docs: +- title: "JEP 454: Foreign Function & Memory API" + href: "https://openjdk.org/jeps/454" +- title: "java.lang.foreign package (Java 22)" + href: "https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/lang/foreign/package-summary.html" diff --git a/content/language/compact-canonical-constructor.yaml b/content/language/compact-canonical-constructor.yaml index 850c8d0..85a25e1 100644 --- a/content/language/compact-canonical-constructor.yaml +++ b/content/language/compact-canonical-constructor.yaml @@ -48,7 +48,7 @@ support: state: "available" description: "Widely available since JDK 16 (March 2021)" prev: "language/static-members-in-inner-classes" -next: "enterprise/servlet-vs-jaxrs" +next: "language/call-c-from-java" related: - "language/records-for-data-classes" - "language/flexible-constructor-bodies" diff --git a/content/language/guarded-patterns.yaml b/content/language/guarded-patterns.yaml index b73288d..b9e613c 100644 --- a/content/language/guarded-patterns.yaml +++ b/content/language/guarded-patterns.yaml @@ -10,7 +10,8 @@ modernLabel: "Java 21+" oldApproach: "Nested if" modernApproach: "when Clause" oldCode: |- - if (shape instanceof Circle c) { + if (shape instanceof Circle) { + Circle c = (Circle) shape; if (c.radius() > 10) { return "large circle"; } else { diff --git a/content/language/pattern-matching-instanceof.yaml b/content/language/pattern-matching-instanceof.yaml index d4306b7..34291c3 100644 --- a/content/language/pattern-matching-instanceof.yaml +++ b/content/language/pattern-matching-instanceof.yaml @@ -12,11 +12,13 @@ modernApproach: "Pattern Variable" oldCode: |- if (obj instanceof String) { String s = (String) obj; - System.out.println(s.length()); + int length = s.length(); + // do something with 'length' } modernCode: |- if (obj instanceof String s) { - IO.println(s.length()); + int length = s.length(); + // do something with 'length' } summary: "Combine type check and cast in one step with pattern matching." explanation: "Pattern matching for instanceof eliminates the redundant cast after\ diff --git a/html-generators/benchmark/run.sh b/html-generators/benchmark/run.sh index 3e0a2fc..f052619 100755 --- a/html-generators/benchmark/run.sh +++ b/html-generators/benchmark/run.sh @@ -31,34 +31,32 @@ UPDATE_MD=false # Helpers # --------------------------------------------------------------------------- measure() { - local t - t=$( { /usr/bin/time -p "$@" > /dev/null; } 2>&1 | awk '/^real/ {print $2}' ) - # Verify the command actually succeeded - if ! "$@" > /dev/null 2>&1; then + 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" - return 1 fi - echo "$t" } avg_runs() { local n="$1"; shift - local sum=0 failures=0 + local sum=0 success=0 for ((i = 1; i <= n; i++)); do local t - t=$(measure "$@") || true - if [[ "$t" == "FAIL" ]]; then - ((failures++)) || true - continue + t=$(measure "$@") + if [[ "$t" != "FAIL" ]]; then + sum=$(echo "$sum + $t" | bc) + success=$((success + 1)) fi - sum=$(echo "$sum + $t" | bc) done - local success=$((n - failures)) if [[ $success -eq 0 ]]; then echo "FAIL" - return 0 + else + echo "scale=2; $sum / $success" | bc | sed 's/^\./0./' fi - echo "scale=2; $sum / $success" | bc | sed 's/^\./0./' } # --------------------------------------------------------------------------- @@ -70,7 +68,7 @@ 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' | wc -l | tr -d ' ') +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" diff --git a/html-generators/generate.py b/html-generators/generate.py index c21f2e2..e63f362 100644 --- a/html-generators/generate.py +++ b/html-generators/generate.py @@ -40,7 +40,7 @@ def _load_properties(path): """Load a .properties file into an OrderedDict, preserving insertion order.""" props = OrderedDict() - with open(path) as f: + with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): @@ -70,7 +70,7 @@ def _find_with_extensions(directory, base_name): def _read_auto(path): """Read a JSON or YAML file based on its extension.""" - with open(path) as f: + 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) @@ -604,7 +604,7 @@ def build_locale(locale, templates, all_snippets): 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="") as f: + 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}") @@ -622,7 +622,7 @@ def build_locale(locale, templates, all_snippets): 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") as f: + 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") @@ -655,7 +655,7 @@ def build_locale(locale, templates, all_snippets): 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") as f: + 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") @@ -667,7 +667,7 @@ def build_locale(locale, templates, all_snippets): def load_templates(): """Load all HTML templates.""" def _read(path): - with open(path) as f: + with open(path, encoding="utf-8") as f: return f.read() return { "page": _read("templates/slug-template.html"), diff --git a/html-generators/generateog.java b/html-generators/generateog.java index f2da013..8918ee8 100644 --- a/html-generators/generateog.java +++ b/html-generators/generateog.java @@ -2,387 +2,31 @@ //JAVA 25 //DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3 //DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3 -//DEPS org.apache.xmlgraphics:batik-transcoder:1.18 -//DEPS org.apache.xmlgraphics:batik-codec:1.18 +//SOURCES og/Palette.java +//SOURCES og/Layout.java +//SOURCES og/ContentLoader.java +//SOURCES og/SyntaxHighlighter.java +//SOURCES og/SvgRenderer.java +//SOURCES og/PngRenderer.java +//SOURCES og/FontManager.java import module java.base; -import com.fasterxml.jackson.databind.*; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import org.apache.batik.transcoder.TranscoderInput; -import org.apache.batik.transcoder.TranscoderOutput; -import org.apache.batik.transcoder.image.PNGTranscoder; +import og.*; +import og.ContentLoader.Snippet; /** - * Generate Open Graph SVG cards (1200×630) for each pattern. - * Light theme, side-by-side Old/Modern code, slug title at top. + * Generate Open Graph SVG + PNG cards (1200×630) for each pattern. + * PNG is rendered directly via Graphics2D (no Batik). * - * Usage: jbang html-generators/generate-og.java [category/slug] + * Usage: jbang html-generators/generateog.java [category/slug] * No arguments → generate all patterns. */ -static final String CONTENT_DIR = "content"; static final String OUTPUT_DIR = "site/og"; -static final String CATEGORIES_FILE = "html-generators/categories.properties"; -static final ObjectMapper JSON_MAPPER = new ObjectMapper(); -static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); -static final Map MAPPERS = Map.of( - "json", JSON_MAPPER, "yaml", YAML_MAPPER, "yml", YAML_MAPPER -); - -static final SequencedMap CATEGORY_DISPLAY = loadProperties(CATEGORIES_FILE); - -// ── Light-theme palette ──────────────────────────────────────────────── -static final String BG = "#ffffff"; -static final String BORDER = "#d8d8e0"; -static final String TEXT = "#1a1a2e"; -static final String TEXT_MUTED = "#6b7280"; -static final String OLD_BG = "#fef2f2"; -static final String MODERN_BG = "#eff6ff"; -static final String OLD_ACCENT = "#dc2626"; -static final String GREEN = "#059669"; -static final String ACCENT = "#6366f1"; -static final String BADGE_BG = "#f3f4f6"; - -// ── Syntax highlight colors (VS Code light-inspired) ─────────────────── -static final String SYN_KEYWORD = "#7c3aed"; // purple — keywords & modifiers -static final String SYN_TYPE = "#0e7490"; // teal — type names -static final String SYN_STRING = "#059669"; // green — strings & chars -static final String SYN_COMMENT = "#6b7280"; // gray — comments -static final String SYN_ANNOTATION = "#b45309"; // amber — annotations -static final String SYN_NUMBER = "#c2410c"; // orange — numeric literals -static final String SYN_DEFAULT = "#1a1a2e"; // dark — everything else - -static final Set JAVA_KEYWORDS = Set.of( - "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" -); - -static final Pattern SYN_PATTERN = Pattern.compile( - "(?//.*)|" + // line comment - "(?/\\*.*?\\*/)|" + // block comment (single line) - "(?@\\w+)|" + // annotation - "(?\"\"\"[\\s\\S]*?\"\"\"|\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*')|" + // strings - "(?\\b\\d[\\d_.]*[dDfFlL]?\\b)|" + // numbers - "(?\\b[A-Za-z_]\\w*\\b)|" + // words (keywords or identifiers) - "(?[^\\s])" // other single chars -); - -// ── Dimensions ───────────────────────────────────────────────────────── -static final int W = 1200, H = 630; -static final int PAD = 40; -static final int HEADER_H = 100; -static final int FOOTER_H = 56; -static final int CODE_TOP = HEADER_H; -static final int CODE_H = H - HEADER_H - FOOTER_H; -static final int COL_W = (W - PAD * 2 - 20) / 2; // 20px gap between panels -static final int CODE_PAD = 14; // padding inside each panel -static final int LABEL_H = 32; // space reserved for label above code -static final int USABLE_W = COL_W - CODE_PAD * 2; // usable width for code text -static final int USABLE_H = CODE_H - LABEL_H - CODE_PAD; // usable height for code text -static final double CHAR_WIDTH_RATIO = 0.6; // monospace char width ≈ 0.6 × font size -static final double LINE_HEIGHT_RATIO = 1.55; // line height ≈ 1.55 × font size -static final int MIN_CODE_FONT = 9; -static final int MAX_CODE_FONT = 16; - -// ── Helpers ──────────────────────────────────────────────────────────── -static final Path FONT_CACHE = Path.of(System.getProperty("user.home"), ".cache", "javaevolved-fonts"); - -static final Map FONT_URLS = Map.of( - "Inter-Regular.ttf", - "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf", - "Inter-Medium.ttf", - "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf", - "Inter-SemiBold.ttf", - "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf", - "Inter-Bold.ttf", - "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf", - "JetBrainsMono-Regular.ttf", - "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPQ.ttf", - "JetBrainsMono-Medium.ttf", - "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8-qxjPQ.ttf" -); - -/** Download fonts to cache and register with Java's graphics environment. */ -static void ensureFonts() throws IOException { - Files.createDirectories(FONT_CACHE); - var ge = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment(); - for (var entry : FONT_URLS.entrySet()) { - var file = FONT_CACHE.resolve(entry.getKey()); - if (!Files.exists(file)) { - IO.println("Downloading %s...".formatted(entry.getKey())); - try (var in = URI.create(entry.getValue()).toURL().openStream()) { - Files.copy(in, file); - } - } - try { - var font = java.awt.Font.createFont(java.awt.Font.TRUETYPE_FONT, file.toFile()); - ge.registerFont(font); - } catch (java.awt.FontFormatException e) { - IO.println("[WARN] Could not register font %s: %s".formatted(entry.getKey(), e.getMessage())); - } - } -} - -/** Convert an SVG string to a PNG file using Batik. */ -static void svgToPng(String svgContent, Path pngPath) throws Exception { - var input = new TranscoderInput(new java.io.StringReader(svgContent)); - try (var out = new java.io.BufferedOutputStream(Files.newOutputStream(pngPath))) { - var transcoder = new PNGTranscoder(); - transcoder.addTranscodingHint(PNGTranscoder.KEY_WIDTH, (float) W * 2); - transcoder.addTranscodingHint(PNGTranscoder.KEY_HEIGHT, (float) H * 2); - transcoder.transcode(input, new TranscoderOutput(out)); - } -} - -static SequencedMap loadProperties(String file) { - try { - var map = new LinkedHashMap(); - for (var line : Files.readAllLines(Path.of(file))) { - line = line.strip(); - if (line.isEmpty() || line.startsWith("#")) continue; - var idx = line.indexOf('='); - if (idx > 0) map.put(line.substring(0, idx).strip(), line.substring(idx + 1).strip()); - } - return map; - } catch (IOException e) { throw new UncheckedIOException(e); } -} - -static String xmlEscape(String s) { - return s == null ? "" - : s.replace("&", "&").replace("<", "<").replace(">", ">") - .replace("\"", """).replace("'", "'"); -} - -record Snippet(JsonNode node) { - String get(String f) { return node.get(f).asText(); } - String slug() { return get("slug"); } - String category() { return get("category"); } - String title() { return get("title"); } - String jdkVersion() { return get("jdkVersion"); } - String oldCode() { return get("oldCode"); } - String modernCode() { return get("modernCode"); } - String oldApproach() { return get("oldApproach"); } - String modernApproach() { return get("modernApproach"); } - String oldLabel() { return get("oldLabel"); } - String modernLabel() { return get("modernLabel"); } - String key() { return category() + "/" + slug(); } - String catDisplay() { return CATEGORY_DISPLAY.get(category()); } -} - -SequencedMap loadAllSnippets() throws IOException { - var snippets = new LinkedHashMap(); - for (var cat : CATEGORY_DISPLAY.sequencedKeySet()) { - var catDir = Path.of(CONTENT_DIR, cat); - if (!Files.isDirectory(catDir)) continue; - var sorted = new ArrayList(); - for (var ext : MAPPERS.keySet()) { - try (var stream = Files.newDirectoryStream(catDir, "*." + ext)) { - stream.forEach(sorted::add); - } - } - sorted.sort(Path::compareTo); - for (var path : sorted) { - var ext = path.getFileName().toString(); - ext = ext.substring(ext.lastIndexOf('.') + 1); - var snippet = new Snippet(MAPPERS.get(ext).readTree(Files.readString(path))); - snippets.put(snippet.key(), snippet); - } - } - return snippets; -} - -// ── SVG rendering ────────────────────────────────────────────────────── - -/** Compute the best font size (MIN–MAX) that fits both code blocks in their panels. */ -static int bestFontSize(List oldLines, List modernLines) { - int maxChars = Math.max( - oldLines.stream().mapToInt(String::length).max().orElse(1), - modernLines.stream().mapToInt(String::length).max().orElse(1) - ); - int maxLines = Math.max(oldLines.size(), modernLines.size()); - - // Largest font where the widest line fits the panel width - int byWidth = (int) (USABLE_W / (maxChars * CHAR_WIDTH_RATIO)); - // Largest font where all lines fit the panel height - int byHeight = (int) (USABLE_H / (maxLines * LINE_HEIGHT_RATIO)); - - return Math.max(MIN_CODE_FONT, Math.min(MAX_CODE_FONT, Math.min(byWidth, byHeight))); -} - -/** Truncate lines to fit the panel height at the given font size. */ -static List fitLines(List lines, int fontSize) { - int lineH = (int) (fontSize * LINE_HEIGHT_RATIO); - int maxLines = USABLE_H / lineH; - if (lines.size() <= maxLines) return lines; - var truncated = new ArrayList<>(lines.subList(0, maxLines - 1)); - truncated.add("..."); - return truncated; -} - -/** Syntax-highlight a single line of Java, returning SVG tspan fragments. */ -static String highlightLine(String line) { - if (line.equals("...")) return xmlEscape(line); - var sb = new StringBuilder(); - var m = SYN_PATTERN.matcher(line); - int last = 0; - while (m.find()) { - // append any skipped whitespace - if (m.start() > last) sb.append(xmlEscape(line.substring(last, m.start()))); - last = m.end(); - var token = m.group(); - String color = null; - if (m.group("comment") != null || m.group("blockcomment") != null) { - color = SYN_COMMENT; - } else if (m.group("annotation") != null) { - color = SYN_ANNOTATION; - } else if (m.group("string") != null) { - color = SYN_STRING; - } else if (m.group("number") != null) { - color = SYN_NUMBER; - } else if (m.group("word") != null) { - if (JAVA_KEYWORDS.contains(token)) { - color = SYN_KEYWORD; - } else if (Character.isUpperCase(token.charAt(0))) { - color = SYN_TYPE; - } - } - if (color != null) { - sb.append("").append(xmlEscape(token)).append(""); - } else { - sb.append(xmlEscape(token)); - } - } - if (last < line.length()) sb.append(xmlEscape(line.substring(last))); - return sb.toString(); -} - -/** Render a column of code lines as SVG elements with syntax highlighting. */ -static String renderCodeBlock(List lines, int x, int y, int lineH) { - var sb = new StringBuilder(); - for (int i = 0; i < lines.size(); i++) { - sb.append(" %s\n" - .formatted(x, y + i * lineH, highlightLine(lines.get(i)))); - } - return sb.toString(); -} - -static String generateSvg(Snippet s) { - int leftX = PAD; - int rightX = PAD + COL_W + 20; - int labelY = CODE_TOP + 26; - int codeY = CODE_TOP + 52; - - var rawOldLines = s.oldCode().lines().toList(); - var rawModernLines = s.modernCode().lines().toList(); - - int fontSize = bestFontSize(rawOldLines, rawModernLines); - int lineH = (int) (fontSize * LINE_HEIGHT_RATIO); - - var oldLines = fitLines(rawOldLines, fontSize); - var modernLines = fitLines(rawModernLines, fontSize); - - return """ - - - - - - - - - - - - - - - - - - - %s - %s - - - - - ✗ %s - -%s - - - - - ✓ %s - -%s - - - JDK %s+ - javaevolved.github.io - -""".formatted( - // viewBox - W, H, W, H, - // style fills - TEXT, TEXT_MUTED, fontSize, TEXT, TEXT_MUTED, ACCENT, - // clip-left - leftX, CODE_TOP, COL_W, CODE_H, - // clip-right - rightX, CODE_TOP, COL_W, CODE_H, - // background - W, H, BG, W - 1, H - 1, BORDER, - // header badge - PAD, 28, xmlEscape(s.catDisplay()).length() * 8 + 16, BADGE_BG, - PAD + 8, 43, xmlEscape(s.catDisplay()), - // title - PAD, 76, xmlEscape(s.title()), - // left panel bg + border - leftX, CODE_TOP, COL_W, CODE_H, OLD_BG, - leftX, CODE_TOP, COL_W, CODE_H, BORDER, - // left label - leftX + 14, labelY, OLD_ACCENT, xmlEscape(s.oldLabel()), - // left code - renderCodeBlock(oldLines, leftX + 14, codeY, lineH), - // right panel bg + border - rightX, CODE_TOP, COL_W, CODE_H, MODERN_BG, - rightX, CODE_TOP, COL_W, CODE_H, BORDER, - // right label - rightX + 14, labelY, GREEN, xmlEscape(s.modernLabel()), - // right code - renderCodeBlock(modernLines, rightX + 14, codeY, lineH), - // footer text - PAD, H - 22, s.jdkVersion(), - W - PAD, H - 22, - // need text-anchor for brand — handled in the template - "" // unused but keeps format args aligned - ).replace( - // Right-align the brand text - "class=\"brand\">javaevolved.github.io", - "class=\"brand\" text-anchor=\"end\">javaevolved.github.io" - ); -} - -// ── Main ─────────────────────────────────────────────────────────────── void main(String... args) throws Exception { - ensureFonts(); + FontManager.ensureFonts(); - var allSnippets = loadAllSnippets(); + var allSnippets = ContentLoader.loadAllSnippets(); IO.println("Loaded %d snippets".formatted(allSnippets.size())); // Filter to a single slug if provided @@ -403,9 +47,8 @@ void main(String... args) throws Exception { for (var s : targets) { var dir = Path.of(OUTPUT_DIR, s.category()); Files.createDirectories(dir); - var svg = generateSvg(s); - Files.writeString(dir.resolve(s.slug() + ".svg"), svg); - svgToPng(svg, dir.resolve(s.slug() + ".png")); + Files.writeString(dir.resolve(s.slug() + ".svg"), SvgRenderer.generate(s)); + PngRenderer.generate(s, dir.resolve(s.slug() + ".png")); count++; } IO.println("Generated %d SVG+PNG card(s) in %s/".formatted(count, OUTPUT_DIR)); diff --git a/html-generators/generateog.py b/html-generators/generateog.py index b76005e..2d2e4d9 100644 --- a/html-generators/generateog.py +++ b/html-generators/generateog.py @@ -98,7 +98,7 @@ def load_properties(path): props = OrderedDict() - with open(path) as f: + with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): @@ -113,7 +113,7 @@ def load_properties(path): def read_auto(path): - with open(path) as f: + 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") @@ -320,7 +320,7 @@ def main(): svg = generate_svg(data) svg_path = os.path.join(out_dir, f"{slug}.svg") - with open(svg_path, "w") as f: + with open(svg_path, "w", encoding="utf-8") as f: f.write(svg) png_path = os.path.join(out_dir, f"{slug}.png") diff --git a/html-generators/generatesocialqueue.java b/html-generators/generatesocialqueue.java new file mode 100644 index 0000000..35a3ed7 --- /dev/null +++ b/html-generators/generatesocialqueue.java @@ -0,0 +1,231 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25 +//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3 +//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3 + +import module java.base; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; + +/** + * Generate social media queue and pre-drafted tweets from content YAML files. + * + * Produces: + * social/queue.txt — shuffled posting order (one category/slug per line) + * social/tweets.yaml — pre-drafted tweet text for each pattern + * + * Re-run behavior: + * - New patterns are appended to the end of the existing queue + * - Deleted/renamed patterns are pruned + * - Existing order and tweet edits are preserved + * - Use --reshuffle to force a full reshuffle + */ + +static final String CONTENT_DIR = "content"; +static final String SOCIAL_DIR = "social"; +static final String QUEUE_FILE = SOCIAL_DIR + "/queue.txt"; +static final String TWEETS_FILE = SOCIAL_DIR + "/tweets.yaml"; +static final String STATE_FILE = SOCIAL_DIR + "/state.yaml"; +static final String BASE_URL = "https://javaevolved.github.io"; +static final int MAX_TWEET_LENGTH = 280; + +static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); +static final ObjectMapper YAML_WRITER = new ObjectMapper( + new YAMLFactory() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) +); + +record PatternInfo(String category, String slug, String title, String summary, + String oldApproach, String modernApproach, String jdkVersion) { + String key() { return category + "/" + slug; } +} + +void main(String... args) throws Exception { + boolean reshuffle = List.of(args).contains("--reshuffle"); + + // 1. Scan all content files + var allPatterns = scanContentFiles(); + System.out.println("Found " + allPatterns.size() + " patterns in content/"); + + // 2. Load existing queue and tweets (if any) + var existingQueue = loadExistingQueue(); + var existingTweets = loadExistingTweets(); + + // 3. Determine new queue order + List queue; + if (reshuffle || existingQueue.isEmpty()) { + // Full shuffle + var keys = new ArrayList<>(allPatterns.keySet()); + Collections.shuffle(keys); + queue = keys; + System.out.println(reshuffle ? "Reshuffled all patterns" : "Generated new queue"); + } else { + // Preserve existing order, prune deleted, append new + queue = new ArrayList<>(); + for (var key : existingQueue) { + if (allPatterns.containsKey(key)) queue.add(key); + else System.out.println(" Pruned (removed): " + key); + } + var existingSet = new LinkedHashSet<>(queue); + var newKeys = new ArrayList(); + for (var key : allPatterns.keySet()) { + if (!existingSet.contains(key)) newKeys.add(key); + } + if (!newKeys.isEmpty()) { + Collections.shuffle(newKeys); + queue.addAll(newKeys); + System.out.println(" Appended " + newKeys.size() + " new patterns: " + newKeys); + } + } + + // 4. Generate tweet drafts + var tweets = new LinkedHashMap(); + int truncated = 0; + for (var key : queue) { + // Preserve manually edited tweets + if (!reshuffle && existingTweets.containsKey(key)) { + tweets.put(key, existingTweets.get(key)); + } else { + var p = allPatterns.get(key); + var tweet = buildTweet(p); + tweets.put(key, tweet); + if (tweet.length() > MAX_TWEET_LENGTH) { + // Retry with truncated summary + tweet = buildTweetTruncated(p); + tweets.put(key, tweet); + truncated++; + } + } + } + + // 5. Validate lengths + int overLength = 0; + for (var entry : tweets.entrySet()) { + int len = entry.getValue().length(); + if (len > MAX_TWEET_LENGTH) { + System.err.println(" WARNING: " + entry.getKey() + " tweet is " + len + " chars (max " + MAX_TWEET_LENGTH + ")"); + overLength++; + } + } + + // 6. Write queue file + Files.createDirectories(Path.of(SOCIAL_DIR)); + Files.writeString(Path.of(QUEUE_FILE), String.join("\n", queue) + "\n"); + System.out.println("Wrote " + QUEUE_FILE + " (" + queue.size() + " entries)"); + + // 7. Write tweets file + YAML_WRITER.writerWithDefaultPrettyPrinter().writeValue(Path.of(TWEETS_FILE).toFile(), tweets); + System.out.println("Wrote " + TWEETS_FILE + " (" + tweets.size() + " entries)"); + + // 8. Create state file if it doesn't exist + if (!Files.exists(Path.of(STATE_FILE))) { + var state = new LinkedHashMap(); + state.put("currentIndex", 1); + state.put("lastPostedKey", null); + state.put("lastTweetId", null); + state.put("lastPostedAt", null); + YAML_WRITER.writerWithDefaultPrettyPrinter().writeValue(Path.of(STATE_FILE).toFile(), state); + System.out.println("Created " + STATE_FILE); + } + + if (truncated > 0) System.out.println(truncated + " tweets were truncated to fit 280 chars"); + if (overLength > 0) System.err.println("WARNING: " + overLength + " tweets still exceed 280 chars — edit manually in " + TWEETS_FILE); + System.out.println("Done!"); +} + +Map scanContentFiles() throws Exception { + var patterns = new LinkedHashMap(); + var contentDir = Path.of(CONTENT_DIR); + + try (var categories = Files.list(contentDir)) { + for (var catDir : categories.filter(Files::isDirectory).sorted().toList()) { + var category = catDir.getFileName().toString(); + try (var files = Files.list(catDir)) { + for (var file : files.filter(f -> isContentFile(f)).sorted().toList()) { + var node = YAML_MAPPER.readTree(file.toFile()); + var slug = node.path("slug").asText(); + var info = new PatternInfo( + category, slug, + node.path("title").asText(), + node.path("summary").asText(), + node.path("oldApproach").asText(), + node.path("modernApproach").asText(), + node.path("jdkVersion").asText() + ); + patterns.put(info.key(), info); + } + } + } + } + return patterns; +} + +boolean isContentFile(Path p) { + var name = p.getFileName().toString(); + return name.endsWith(".yaml") || name.endsWith(".yml") || name.endsWith(".json"); +} + +List loadExistingQueue() throws Exception { + var path = Path.of(QUEUE_FILE); + if (!Files.exists(path)) return List.of(); + return Files.readAllLines(path).stream() + .map(String::strip) + .filter(s -> !s.isEmpty()) + .toList(); +} + +@SuppressWarnings("unchecked") +Map loadExistingTweets() throws Exception { + var path = Path.of(TWEETS_FILE); + if (!Files.exists(path)) return Map.of(); + return YAML_MAPPER.readValue(path.toFile(), LinkedHashMap.class); +} + +String buildTweet(PatternInfo p) { + return """ + ☕ %s + + %s + + %s → %s (JDK %s+) + + 🔗 %s/%s/%s.html + + #Java #JavaEvolved""".formatted( + p.title(), p.summary(), + p.oldApproach(), p.modernApproach(), p.jdkVersion(), + BASE_URL, p.category(), p.slug() + ).stripIndent().strip(); +} + +String buildTweetTruncated(PatternInfo p) { + // Calculate budget: total minus everything except summary + var template = """ + ☕ %s + + %s + + %s → %s (JDK %s+) + + 🔗 %s/%s/%s.html + + #Java #JavaEvolved""".stripIndent().strip(); + + var withoutSummary = template.formatted( + p.title(), "", + p.oldApproach(), p.modernApproach(), p.jdkVersion(), + BASE_URL, p.category(), p.slug() + ); + int budget = MAX_TWEET_LENGTH - withoutSummary.length(); + var summary = p.summary(); + if (summary.length() > budget && budget > 3) { + summary = summary.substring(0, budget - 1) + "…"; + } + return template.formatted( + p.title(), summary, + p.oldApproach(), p.modernApproach(), p.jdkVersion(), + BASE_URL, p.category(), p.slug() + ); +} diff --git a/html-generators/locales.properties b/html-generators/locales.properties index 40c7bf6..422c4dd 100644 --- a/html-generators/locales.properties +++ b/html-generators/locales.properties @@ -8,6 +8,8 @@ ar=🇸🇦 العربية fr=🇫🇷 Français ja=🇯🇵 日本語 ko=🇰🇷 한국어 +bn=🇧🇩 বাংলা it=🇮🇹 Italiano pl=🇵🇱 Polski -tr=🇹🇷 Türkçe \ No newline at end of file +tr=🇹🇷 Türkçe +ru=🇷🇺 Русский \ No newline at end of file diff --git a/html-generators/og/ContentLoader.java b/html-generators/og/ContentLoader.java new file mode 100644 index 0000000..c42db92 --- /dev/null +++ b/html-generators/og/ContentLoader.java @@ -0,0 +1,88 @@ +package og; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.SequencedMap; + +/** Loads content JSON/YAML files and category properties. */ +public final class ContentLoader { + + static final String CONTENT_DIR = "content"; + static final String CATEGORIES_FILE = "html-generators/categories.properties"; + + static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + static final Map MAPPERS = Map.of( + "json", JSON_MAPPER, "yaml", YAML_MAPPER, "yml", YAML_MAPPER + ); + + public static final SequencedMap CATEGORY_DISPLAY = loadProperties(CATEGORIES_FILE); + + public record Snippet(JsonNode node) { + public String get(String f) { return node.get(f).asText(); } + public String slug() { return get("slug"); } + public String category() { return get("category"); } + public String title() { return get("title"); } + public String jdkVersion() { return get("jdkVersion"); } + public String oldCode() { return get("oldCode"); } + public String modernCode() { return get("modernCode"); } + public String oldApproach() { return get("oldApproach"); } + public String modernApproach() { return get("modernApproach"); } + public String oldLabel() { return get("oldLabel"); } + public String modernLabel() { return get("modernLabel"); } + public String key() { return category() + "/" + slug(); } + public String catDisplay() { return CATEGORY_DISPLAY.get(category()); } + } + + public static SequencedMap loadAllSnippets() throws IOException { + var snippets = new LinkedHashMap(); + for (var cat : CATEGORY_DISPLAY.sequencedKeySet()) { + var catDir = Path.of(CONTENT_DIR, cat); + if (!Files.isDirectory(catDir)) continue; + var sorted = new ArrayList(); + for (var ext : MAPPERS.keySet()) { + try (var stream = Files.newDirectoryStream(catDir, "*." + ext)) { + stream.forEach(sorted::add); + } + } + sorted.sort(Path::compareTo); + for (var path : sorted) { + var ext = path.getFileName().toString(); + ext = ext.substring(ext.lastIndexOf('.') + 1); + var snippet = new Snippet(MAPPERS.get(ext).readTree(Files.readString(path))); + snippets.put(snippet.key(), snippet); + } + } + return snippets; + } + + public static SequencedMap loadProperties(String file) { + try { + var map = new LinkedHashMap(); + for (var line : Files.readAllLines(Path.of(file))) { + line = line.strip(); + if (line.isEmpty() || line.startsWith("#")) continue; + var idx = line.indexOf('='); + if (idx > 0) map.put(line.substring(0, idx).strip(), line.substring(idx + 1).strip()); + } + return map; + } catch (IOException e) { throw new UncheckedIOException(e); } + } + + public static String xmlEscape(String s) { + return s == null ? "" + : s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } + + private ContentLoader() {} +} diff --git a/html-generators/og/FontManager.java b/html-generators/og/FontManager.java new file mode 100644 index 0000000..d5d37fb --- /dev/null +++ b/html-generators/og/FontManager.java @@ -0,0 +1,66 @@ +package og; + +import java.awt.Font; +import java.awt.FontFormatException; +import java.awt.GraphicsEnvironment; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** Downloads and registers Inter + JetBrains Mono fonts. */ +public final class FontManager { + + static final Path FONT_CACHE = Path.of( + System.getProperty("user.home"), ".cache", "javaevolved-fonts"); + + static final Map FONT_URLS = Map.of( + "Inter-Regular.ttf", + "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf", + "Inter-Medium.ttf", + "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf", + "Inter-SemiBold.ttf", + "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf", + "Inter-Bold.ttf", + "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf", + "JetBrainsMono-Regular.ttf", + "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPQ.ttf", + "JetBrainsMono-Medium.ttf", + "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8-qxjPQ.ttf" + ); + + /** Download fonts to cache and register with Java's graphics environment. */ + public static void ensureFonts() throws IOException { + Files.createDirectories(FONT_CACHE); + var ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); + for (var entry : FONT_URLS.entrySet()) { + var file = FONT_CACHE.resolve(entry.getKey()); + if (!Files.exists(file)) { + System.out.println("Downloading %s...".formatted(entry.getKey())); + try (var in = URI.create(entry.getValue()).toURL().openStream()) { + Files.copy(in, file); + } + } + try { + var font = Font.createFont(Font.TRUETYPE_FONT, file.toFile()); + ge.registerFont(font); + } catch (FontFormatException e) { + System.out.println("[WARN] Could not register font %s: %s" + .formatted(entry.getKey(), e.getMessage())); + } + } + } + + /** Load a specific font file at the given size. */ + public static Font getFont(String filename, float size) { + try { + var path = FONT_CACHE.resolve(filename); + return Font.createFont(Font.TRUETYPE_FONT, path.toFile()).deriveFont(size); + } catch (FontFormatException | IOException e) { + throw new RuntimeException("Cannot load font: " + filename, e); + } + } + + private FontManager() {} +} diff --git a/html-generators/og/Layout.java b/html-generators/og/Layout.java new file mode 100644 index 0000000..b86f76e --- /dev/null +++ b/html-generators/og/Layout.java @@ -0,0 +1,48 @@ +package og; + +import java.util.ArrayList; +import java.util.List; + +/** Card dimensions and code-fitting helpers. */ +public final class Layout { + + public static final int W = 1200, H = 630; + public static final int PAD = 40; + public static final int HEADER_H = 100; + public static final int FOOTER_H = 56; + public static final int CODE_TOP = HEADER_H; + public static final int CODE_H = H - HEADER_H - FOOTER_H; + public static final int COL_W = (W - PAD * 2 - 20) / 2; // 20px gap between panels + public static final int CODE_PAD = 14; + public static final int LABEL_H = 32; + public static final int USABLE_W = COL_W - CODE_PAD * 2; + public static final int USABLE_H = CODE_H - LABEL_H - CODE_PAD; + public static final double CHAR_WIDTH_RATIO = 0.6; + public static final double LINE_HEIGHT_RATIO = 1.55; + public static final int MIN_CODE_FONT = 9; + public static final int MAX_CODE_FONT = 16; + + /** Compute the best font size (MIN–MAX) that fits both code blocks. */ + public static int bestFontSize(List oldLines, List modernLines) { + int maxChars = Math.max( + oldLines.stream().mapToInt(String::length).max().orElse(1), + modernLines.stream().mapToInt(String::length).max().orElse(1) + ); + int maxLines = Math.max(oldLines.size(), modernLines.size()); + int byWidth = (int) (USABLE_W / (maxChars * CHAR_WIDTH_RATIO)); + int byHeight = (int) (USABLE_H / (maxLines * LINE_HEIGHT_RATIO)); + return Math.max(MIN_CODE_FONT, Math.min(MAX_CODE_FONT, Math.min(byWidth, byHeight))); + } + + /** Truncate lines to fit the panel height at the given font size. */ + public static List fitLines(List lines, int fontSize) { + int lineH = (int) (fontSize * LINE_HEIGHT_RATIO); + int maxLines = USABLE_H / lineH; + if (lines.size() <= maxLines) return lines; + var truncated = new ArrayList<>(lines.subList(0, maxLines - 1)); + truncated.add("..."); + return truncated; + } + + private Layout() {} +} diff --git a/html-generators/og/Palette.java b/html-generators/og/Palette.java new file mode 100644 index 0000000..a32f2a9 --- /dev/null +++ b/html-generators/og/Palette.java @@ -0,0 +1,34 @@ +package og; + +import java.awt.Color; + +/** Light-theme color palette for OG cards. */ +public final class Palette { + + // Background & chrome + public static final String BG = "#ffffff"; + public static final String BORDER = "#d8d8e0"; + public static final String TEXT = "#1a1a2e"; + public static final String TEXT_MUTED = "#6b7280"; + public static final String OLD_BG = "#fef2f2"; + public static final String MODERN_BG = "#eff6ff"; + public static final String OLD_ACCENT = "#dc2626"; + public static final String GREEN = "#059669"; + public static final String ACCENT = "#6366f1"; + public static final String BADGE_BG = "#f3f4f6"; + + // Syntax highlighting (VS Code light-inspired) + public static final String SYN_KEYWORD = "#7c3aed"; // purple + public static final String SYN_TYPE = "#0e7490"; // teal + public static final String SYN_STRING = "#059669"; // green + public static final String SYN_COMMENT = "#6b7280"; // gray + public static final String SYN_ANNOTATION = "#b45309"; // amber + public static final String SYN_NUMBER = "#c2410c"; // orange + public static final String SYN_DEFAULT = "#1a1a2e"; // dark + + public static Color color(String hex) { + return Color.decode(hex); + } + + private Palette() {} +} diff --git a/html-generators/og/PngRenderer.java b/html-generators/og/PngRenderer.java new file mode 100644 index 0000000..0cb164f --- /dev/null +++ b/html-generators/og/PngRenderer.java @@ -0,0 +1,164 @@ +package og; + +import og.ContentLoader.Snippet; +import og.SyntaxHighlighter.Token; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.font.FontRenderContext; +import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import javax.imageio.ImageIO; + +import static og.Layout.*; +import static og.Palette.*; + +/** + * Renders OG card PNGs directly with Graphics2D — no SVG intermediary, + * no Batik dependency. Produces 2× resolution (2400×1260) images. + */ +public final class PngRenderer { + + static final int SCALE = 2; + + // Reuse a single BufferedImage across all cards to avoid 12MB humongous allocations per card + static final BufferedImage SHARED_IMG = new BufferedImage(W * SCALE, H * SCALE, BufferedImage.TYPE_INT_RGB); + + public static void generate(Snippet s, Path outputPath) throws IOException { + var g = SHARED_IMG.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g.scale(SCALE, SCALE); + + // White fill (clears previous card) + g.setColor(Color.WHITE); + g.fillRect(0, 0, W, H); + + drawCard(g, s); + g.dispose(); + ImageIO.write(SHARED_IMG, "PNG", outputPath.toFile()); + } + + static void drawCard(Graphics2D g, Snippet s) { + // Background with rounded corners + var bg = new RoundRectangle2D.Double(0, 0, W, H, 16, 16); + g.setColor(Palette.color(BG)); + g.fill(bg); + g.setColor(Palette.color(BORDER)); + g.setStroke(new BasicStroke(1f)); + g.draw(new RoundRectangle2D.Double(0.5, 0.5, W - 1, H - 1, 16, 16)); + + drawHeader(g, s); + + // Compute font size and fit lines + var rawOldLines = s.oldCode().lines().toList(); + var rawModernLines = s.modernCode().lines().toList(); + int fontSize = bestFontSize(rawOldLines, rawModernLines); + int lineH = (int) (fontSize * LINE_HEIGHT_RATIO); + var oldLines = fitLines(rawOldLines, fontSize); + var modernLines = fitLines(rawModernLines, fontSize); + + int leftX = PAD; + int rightX = PAD + COL_W + 20; + + drawCodePanel(g, oldLines, leftX, OLD_BG, OLD_ACCENT, + "\u2717 " + s.oldLabel(), fontSize, lineH); + drawCodePanel(g, modernLines, rightX, MODERN_BG, GREEN, + "\u2713 " + s.modernLabel(), fontSize, lineH); + + drawFooter(g, s); + } + + static void drawHeader(Graphics2D g, Snippet s) { + var catText = s.catDisplay(); + var categoryFont = FontManager.getFont("Inter-SemiBold.ttf", 13f); + var titleFont = FontManager.getFont("Inter-Bold.ttf", 24f); + + // Category badge + var fm = g.getFontMetrics(categoryFont); + int badgeW = fm.stringWidth(catText) + 16; + g.setColor(Palette.color(BADGE_BG)); + g.fill(new RoundRectangle2D.Double(PAD, 28, badgeW, 22, 6, 6)); + g.setFont(categoryFont); + g.setColor(Palette.color(TEXT_MUTED)); + g.drawString(catText, PAD + 8, 43); + + // Title + g.setFont(titleFont); + g.setColor(Palette.color(TEXT)); + g.drawString(s.title(), PAD, 76); + } + + static void drawCodePanel(Graphics2D g, List lines, int panelX, + String bgHex, String labelColorHex, String labelText, + int fontSize, int lineH) { + + // Panel background + var panelRect = new RoundRectangle2D.Double(panelX, CODE_TOP, COL_W, CODE_H, 8, 8); + g.setColor(Palette.color(bgHex)); + g.fill(panelRect); + g.setColor(Palette.color(BORDER)); + g.setStroke(new BasicStroke(0.5f)); + g.draw(panelRect); + + // Label + var labelFont = FontManager.getFont("Inter-SemiBold.ttf", 11f); + g.setFont(labelFont); + g.setColor(Palette.color(labelColorHex)); + g.drawString(labelText.toUpperCase(), panelX + 14, CODE_TOP + 26); + + // Code lines (clipped to panel) + Shape oldClip = g.getClip(); + g.setClip(panelRect); + var codeFont = FontManager.getFont("JetBrainsMono-Regular.ttf", (float) fontSize); + g.setFont(codeFont); + var frc = g.getFontRenderContext(); + int codeY = CODE_TOP + 52; + + for (int i = 0; i < lines.size(); i++) { + var tokens = SyntaxHighlighter.tokenize(lines.get(i)); + drawTokens(g, tokens, panelX + 14, codeY + i * lineH, codeFont, frc); + } + g.setClip(oldClip); + } + + static void drawTokens(Graphics2D g, List tokens, int x, int y, + Font codeFont, FontRenderContext frc) { + float curX = x; + for (var token : tokens) { + g.setColor(token.color() != null + ? Palette.color(token.color()) + : Palette.color(SYN_DEFAULT)); + g.drawString(token.text(), curX, y); + curX += (float) codeFont.getStringBounds(token.text(), frc).getWidth(); + } + } + + static void drawFooter(Graphics2D g, Snippet s) { + var footerFont = FontManager.getFont("Inter-Medium.ttf", 13f); + var brandFont = FontManager.getFont("Inter-Bold.ttf", 14f); + + // JDK version + g.setFont(footerFont); + g.setColor(Palette.color(TEXT_MUTED)); + g.drawString("JDK %s+".formatted(s.jdkVersion()), PAD, H - 22); + + // Brand (right-aligned) + g.setFont(brandFont); + g.setColor(Palette.color(ACCENT)); + var brand = "javaevolved.github.io"; + var fm = g.getFontMetrics(brandFont); + g.drawString(brand, W - PAD - fm.stringWidth(brand), H - 22); + } + + private PngRenderer() {} +} diff --git a/html-generators/og/SvgRenderer.java b/html-generators/og/SvgRenderer.java new file mode 100644 index 0000000..24e0701 --- /dev/null +++ b/html-generators/og/SvgRenderer.java @@ -0,0 +1,121 @@ +package og; + +import og.ContentLoader.Snippet; + +import java.util.List; + +import static og.ContentLoader.xmlEscape; +import static og.Layout.*; +import static og.Palette.*; + +/** Generates SVG card markup for a snippet. */ +public final class SvgRenderer { + + static String renderCodeBlock(List lines, int x, int y, int lineH) { + var sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + var tokens = SyntaxHighlighter.tokenize(lines.get(i)); + sb.append(" %s\n" + .formatted(x, y + i * lineH, SyntaxHighlighter.tokensToSvg(tokens))); + } + return sb.toString(); + } + + public static String generate(Snippet s) { + int leftX = PAD; + int rightX = PAD + COL_W + 20; + int labelY = CODE_TOP + 26; + int codeY = CODE_TOP + 52; + + var rawOldLines = s.oldCode().lines().toList(); + var rawModernLines = s.modernCode().lines().toList(); + int fontSize = bestFontSize(rawOldLines, rawModernLines); + int lineH = (int) (fontSize * LINE_HEIGHT_RATIO); + var oldLines = fitLines(rawOldLines, fontSize); + var modernLines = fitLines(rawModernLines, fontSize); + + return """ + + + + + + + + + + + + + + + + + + + %s + %s + + + + + ✗ %s + +%s + + + + + ✓ %s + +%s + + + JDK %s+ + javaevolved.github.io + +""".formatted( + // viewBox + W, H, W, H, + // style fills + TEXT, TEXT_MUTED, fontSize, TEXT, TEXT_MUTED, ACCENT, + // clip-left + leftX, CODE_TOP, COL_W, CODE_H, + // clip-right + rightX, CODE_TOP, COL_W, CODE_H, + // background + W, H, BG, W - 1, H - 1, BORDER, + // header badge + PAD, 28, xmlEscape(s.catDisplay()).length() * 8 + 16, BADGE_BG, + PAD + 8, 43, xmlEscape(s.catDisplay()), + // title + PAD, 76, xmlEscape(s.title()), + // left panel bg + border + leftX, CODE_TOP, COL_W, CODE_H, OLD_BG, + leftX, CODE_TOP, COL_W, CODE_H, BORDER, + // left label + leftX + 14, labelY, OLD_ACCENT, xmlEscape(s.oldLabel()), + // left code + renderCodeBlock(oldLines, leftX + 14, codeY, lineH), + // right panel bg + border + rightX, CODE_TOP, COL_W, CODE_H, MODERN_BG, + rightX, CODE_TOP, COL_W, CODE_H, BORDER, + // right label + rightX + 14, labelY, GREEN, xmlEscape(s.modernLabel()), + // right code + renderCodeBlock(modernLines, rightX + 14, codeY, lineH), + // footer + PAD, H - 22, s.jdkVersion(), + W - PAD, H - 22 + ); + } + + private SvgRenderer() {} +} diff --git a/html-generators/og/SyntaxHighlighter.java b/html-generators/og/SyntaxHighlighter.java new file mode 100644 index 0000000..fe10921 --- /dev/null +++ b/html-generators/og/SyntaxHighlighter.java @@ -0,0 +1,85 @@ +package og; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** Tokenizes Java source lines into colored segments for SVG and PNG rendering. */ +public final class SyntaxHighlighter { + + public record Token(String text, String color) {} + + static final Set JAVA_KEYWORDS = Set.of( + "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" + ); + + static final Pattern SYN_PATTERN = Pattern.compile( + "(?//.*)|" + + "(?/\\*.*?\\*/)|" + + "(?@\\w+)|" + + "(?\"\"\"[\\s\\S]*?\"\"\"|\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*')|" + + "(?\\b\\d[\\d_.]*[dDfFlL]?\\b)|" + + "(?\\b[A-Za-z_]\\w*\\b)|" + + "(?[^\\s])" + ); + + /** Tokenize a line of Java code into colored segments. */ + public static List tokenize(String line) { + if (line.equals("...")) return List.of(new Token("...", null)); + var tokens = new ArrayList(); + var m = SYN_PATTERN.matcher(line); + int last = 0; + while (m.find()) { + if (m.start() > last) + tokens.add(new Token(line.substring(last, m.start()), null)); + last = m.end(); + var text = m.group(); + String color = null; + if (m.group("comment") != null || m.group("blockcomment") != null) { + color = Palette.SYN_COMMENT; + } else if (m.group("annotation") != null) { + color = Palette.SYN_ANNOTATION; + } else if (m.group("string") != null) { + color = Palette.SYN_STRING; + } else if (m.group("number") != null) { + color = Palette.SYN_NUMBER; + } else if (m.group("word") != null) { + if (JAVA_KEYWORDS.contains(text)) { + color = Palette.SYN_KEYWORD; + } else if (Character.isUpperCase(text.charAt(0))) { + color = Palette.SYN_TYPE; + } + } + tokens.add(new Token(text, color)); + } + if (last < line.length()) + tokens.add(new Token(line.substring(last), null)); + return tokens; + } + + /** Convert tokens to SVG tspan fragments. */ + public static String tokensToSvg(List tokens) { + var sb = new StringBuilder(); + for (var t : tokens) { + if (t.color() != null) { + sb.append("") + .append(ContentLoader.xmlEscape(t.text())).append(""); + } else { + sb.append(ContentLoader.xmlEscape(t.text())); + } + } + return sb.toString(); + } + + private SyntaxHighlighter() {} +} diff --git a/html-generators/socialpost.java b/html-generators/socialpost.java new file mode 100644 index 0000000..282008e --- /dev/null +++ b/html-generators/socialpost.java @@ -0,0 +1,206 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25 +//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3 +//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3 + +import module java.base; +import java.net.http.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; + +/** + * Post the next tweet from the social queue to Twitter/X. + * + * Reads state from social/state.yaml, posts via Twitter API v2, + * and updates state only after confirmed API success. + * + * Required environment variables: + * TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_KEY_SECRET, + * TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET + * + * Options: + * --dry-run Print the tweet without posting + */ + +static final String SOCIAL_DIR = "social"; +static final String QUEUE_FILE = SOCIAL_DIR + "/queue.txt"; +static final String TWEETS_FILE = SOCIAL_DIR + "/tweets.yaml"; +static final String STATE_FILE = SOCIAL_DIR + "/state.yaml"; +static final String TWITTER_API_URL = "https://api.twitter.com/2/tweets"; + +static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); +static final ObjectMapper YAML_WRITER = new ObjectMapper( + new YAMLFactory() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) +); +static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + +void main(String... args) throws Exception { + boolean dryRun = List.of(args).contains("--dry-run"); + + // 1. Load queue, tweets, state + var queue = loadQueue(); + var tweets = loadTweets(); + var state = loadState(); + + int currentIndex = ((Number) state.get("currentIndex")).intValue(); + System.out.println("Queue has " + queue.size() + " entries, current index: " + currentIndex); + + // 2. Check if queue is exhausted + if (currentIndex > queue.size()) { + System.out.println("Queue exhausted — reshuffle needed."); + System.out.println("Run: jbang html-generators/generatesocialqueue.java --reshuffle"); + System.exit(1); + } + + // 3. Get the current pattern key and tweet text + var key = queue.get(currentIndex - 1); // 1-based index + var tweetText = tweets.get(key); + + if (tweetText == null) { + System.err.println("ERROR: No tweet text found for key: " + key); + System.err.println("Regenerate tweets: jbang html-generators/generatesocialqueue.java"); + System.exit(1); + } + + System.out.println("Pattern: " + key); + System.out.println("Tweet (" + tweetText.length() + " chars):"); + System.out.println("---"); + System.out.println(tweetText); + System.out.println("---"); + + if (dryRun) { + System.out.println("DRY RUN — not posting."); + return; + } + + // 4. Read Twitter credentials from environment + var consumerKey = requireEnv("TWITTER_CONSUMER_KEY"); + var consumerSecret = requireEnv("TWITTER_CONSUMER_KEY_SECRET"); + var accessToken = requireEnv("TWITTER_ACCESS_TOKEN"); + var accessTokenSecret = requireEnv("TWITTER_ACCESS_TOKEN_SECRET"); + + // 5. Post to Twitter + var tweetId = postTweet(tweetText, consumerKey, consumerSecret, accessToken, accessTokenSecret); + System.out.println("Posted! Tweet ID: " + tweetId); + + // 6. Update state only after success + state.put("currentIndex", currentIndex + 1); + state.put("lastPostedKey", key); + state.put("lastTweetId", tweetId); + state.put("lastPostedAt", java.time.Instant.now().toString()); + YAML_WRITER.writerWithDefaultPrettyPrinter().writeValue(Path.of(STATE_FILE).toFile(), state); + System.out.println("State updated: index now " + (currentIndex + 1)); +} + +// --- Twitter API v2 with OAuth 1.0a --- + +String postTweet(String text, String consumerKey, String consumerSecret, + String token, String tokenSecret) throws Exception { + var method = "POST"; + var url = TWITTER_API_URL; + + // OAuth parameters + var oauthParams = new TreeMap(); + oauthParams.put("oauth_consumer_key", consumerKey); + oauthParams.put("oauth_nonce", generateNonce()); + oauthParams.put("oauth_signature_method", "HMAC-SHA1"); + oauthParams.put("oauth_timestamp", String.valueOf(Instant.now().getEpochSecond())); + oauthParams.put("oauth_token", token); + oauthParams.put("oauth_version", "1.0"); + + // Build signature base string (no body params for JSON content type) + var paramString = oauthParams.entrySet().stream() + .map(e -> percentEncode(e.getKey()) + "=" + percentEncode(e.getValue())) + .collect(Collectors.joining("&")); + + var baseString = method + "&" + percentEncode(url) + "&" + percentEncode(paramString); + var signingKey = percentEncode(consumerSecret) + "&" + percentEncode(tokenSecret); + + var signature = hmacSha1(signingKey, baseString); + oauthParams.put("oauth_signature", signature); + + // Build Authorization header + var authHeader = "OAuth " + oauthParams.entrySet().stream() + .map(e -> percentEncode(e.getKey()) + "=\"" + percentEncode(e.getValue()) + "\"") + .collect(Collectors.joining(", ")); + + // Build JSON body + var bodyMap = Map.of("text", text); + var body = JSON_MAPPER.writeValueAsString(bodyMap); + + // Send request + var client = HttpClient.newHttpClient(); + var request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", authHeader) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 201) { + System.err.println("Twitter API error (HTTP " + response.statusCode() + "):"); + System.err.println(response.body()); + System.exit(1); + } + + var responseNode = JSON_MAPPER.readTree(response.body()); + return responseNode.path("data").path("id").asText(); +} + +String generateNonce() { + var bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return HexFormat.of().formatHex(bytes); +} + +String hmacSha1(String key, String data) throws Exception { + var mac = javax.crypto.Mac.getInstance("HmacSHA1"); + mac.init(new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + var raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(raw); +} + +String percentEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); +} + +// --- File helpers --- + +List loadQueue() throws Exception { + var lines = Files.readAllLines(Path.of(QUEUE_FILE)).stream() + .map(String::strip) + .filter(s -> !s.isEmpty()) + .toList(); + if (lines.isEmpty()) { + System.err.println("ERROR: " + QUEUE_FILE + " is empty. Run the queue generator first."); + System.exit(1); + } + return lines; +} + +@SuppressWarnings("unchecked") +Map loadTweets() throws Exception { + return YAML_MAPPER.readValue(Path.of(TWEETS_FILE).toFile(), LinkedHashMap.class); +} + +@SuppressWarnings("unchecked") +Map loadState() throws Exception { + return YAML_MAPPER.readValue(Path.of(STATE_FILE).toFile(), LinkedHashMap.class); +} + +String requireEnv(String name) { + var value = System.getenv(name); + if (value == null || value.isBlank()) { + System.err.println("ERROR: Missing environment variable: " + name); + System.exit(1); + } + return value; +} diff --git a/proof/language/CallCFromJava.java b/proof/language/CallCFromJava.java new file mode 100644 index 0000000..07322d3 --- /dev/null +++ b/proof/language/CallCFromJava.java @@ -0,0 +1,25 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 25+ +//JAVA_OPTIONS --enable-native-access=ALL-UNNAMED + +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; +import java.util.Optional; + +/// Proof: call-c-from-java +/// Source: content/language/call-c-from-java.yaml +void main() throws Throwable { + + try (Arena arena = Arena.ofConfined()) { + // Use a system library to prove FFM compiles and links + SymbolLookup stdlib = Linker.nativeLinker().defaultLookup(); + Optional segment = stdlib.find("strlen"); + MemorySegment foreignFuncAddr = segment.get(); + FunctionDescriptor strlen_sig = + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS); + MethodHandle strlenMethod = + Linker.nativeLinker().downcallHandle(foreignFuncAddr, strlen_sig); + var ret = (long) strlenMethod.invokeExact(arena.allocateFrom("Bambi")); + System.out.println("Return value " + ret); + } +} diff --git a/secrets.md b/secrets.md new file mode 100644 index 0000000..ade7008 --- /dev/null +++ b/secrets.md @@ -0,0 +1,32 @@ +# Repository Secrets + +This document lists the GitHub repository secrets configured in **Settings → Secrets and variables → Actions**. + +> **Note:** Secret values are never stored in the repository. They are managed exclusively through GitHub's encrypted secrets. See [GitHub docs](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) for details. + +## Secrets + +| Secret | Purpose | +|--------|---------| +| `COPILOT_GITHUB_TOKEN` | GitHub token used by Copilot for automated workflows | +| `TWITTER_APP_CONSUMER_KEY` | X (Twitter) API v2 OAuth 1.0a consumer key | +| `TWITTER_APP_SECRET_KEY` | X (Twitter) API v2 OAuth 1.0a consumer secret | +| `TWITTER_ACCESS_TOKEN` | X (Twitter) API v2 user access token | +| `TWITTER_ACCESS_TOKEN_SECRET` | X (Twitter) API v2 user access token secret | +| `TWITTER_APP_BEARER_TOKEN` | X (Twitter) API v2 OAuth 2.0 App-Only bearer token | +| `TWITTER_CLIENT_ID` | X (Twitter) OAuth 2.0 client ID | +| `TWITTER_CLIENT_SECRET` | X (Twitter) OAuth 2.0 client secret | + +## Usage + +### `COPILOT_GITHUB_TOKEN` + +Used by GitHub Copilot integrations. Not currently referenced in any workflow file. + +### Twitter / X Secrets + +Used for automated twice-weekly social media posting of Java pattern updates to X (Twitter) via the X API v2. + +- **Workflow:** [`.github/workflows/social-post.yml`](.github/workflows/social-post.yml) — runs every Monday and Thursday at 14:00 UTC +- **Post script:** [`html-generators/socialpost.java`](html-generators/socialpost.java) +- **Spec:** [`specs/social-posts-spec.md`](specs/social-posts-spec.md) diff --git a/site/app.js b/site/app.js index 2b5476f..d38f0e3 100644 --- a/site/app.js +++ b/site/app.js @@ -216,7 +216,10 @@ '25': [22, 25] }; + const noResultsMsg = document.getElementById('noResultsMessage'); + const applyFilters = () => { + let visibleCount = 0; cards.forEach(card => { const matchesCategory = !activeCategory || card.dataset.category === activeCategory; let matchesJdk = true; @@ -225,9 +228,15 @@ const range = LTS_RANGES[activeJdk]; matchesJdk = range && version >= range[0] && version <= range[1]; } - card.classList.toggle('filter-hidden', !(matchesCategory && matchesJdk)); + const visible = matchesCategory && matchesJdk; + card.classList.toggle('filter-hidden', !visible); + if (visible) visibleCount++; }); + if (noResultsMsg) { + noResultsMsg.style.display = visibleCount === 0 ? '' : 'none'; + } + if (window.updateViewToggleState) { window.updateViewToggleState(); } diff --git a/site/styles.css b/site/styles.css index 8744f2d..b1c6571 100644 --- a/site/styles.css +++ b/site/styles.css @@ -415,6 +415,14 @@ nav { display: none; } +.no-results-message { + text-align: center; + color: var(--text-muted); + padding: 3rem 1rem; + font-size: 1.1rem; + width: 100%; +} + .tip-card { background: var(--surface); border: 1px solid var(--border); diff --git a/social/queue.txt b/social/queue.txt new file mode 100644 index 0000000..ced2434 --- /dev/null +++ b/social/queue.txt @@ -0,0 +1,113 @@ +language/guarded-patterns +datetime/math-clamp +streams/optional-ifpresentorelse +strings/string-repeat +language/unnamed-variables +security/strong-random +io/writing-files +language/pattern-matching-instanceof +language/static-methods-in-interfaces +language/module-import-declarations +strings/string-strip +tooling/multi-file-source +datetime/hex-format +language/sealed-classes +strings/string-isblank +enterprise/servlet-vs-jaxrs +enterprise/jdbc-vs-jooq +collections/reverse-list-iteration +enterprise/jpa-vs-jakarta-data +tooling/jfr-profiling +concurrency/stable-values +enterprise/jdbc-resultset-vs-jpa-criteria +language/type-inference-with-var +streams/predicate-not +concurrency/process-api +datetime/java-time-basics +language/pattern-matching-switch +language/static-members-in-inner-classes +language/record-patterns +errors/helpful-npe +enterprise/spring-null-safety-jspecify +concurrency/executor-try-with-resources +streams/stream-tolist +strings/string-lines +collections/map-entry-factory +concurrency/scoped-values +language/markdown-javadoc-comments +concurrency/completablefuture-chaining +enterprise/spring-api-versioning +io/http-client +streams/stream-mapmulti +strings/string-formatted +datetime/duration-and-period +errors/optional-orelsethrow +io/path-of +collections/unmodifiable-collectors +enterprise/manual-transaction-vs-declarative +security/tls-default +enterprise/jndi-lookup-vs-cdi-injection +errors/null-in-switch +datetime/instant-precision +language/primitive-types-in-patterns +language/text-blocks-for-multiline-strings +io/file-memory-mapping +tooling/junit6-with-jspecify +streams/virtual-thread-executor +language/switch-expressions +errors/record-based-errors +streams/collectors-flatmapping +language/diamond-operator +language/compact-canonical-constructor +tooling/aot-class-preloading +io/deserialization-filters +enterprise/spring-xml-config-vs-annotations +security/key-derivation-functions +concurrency/virtual-threads +collections/immutable-list-creation +enterprise/jsf-managed-bean-vs-cdi-named +errors/multi-catch +io/try-with-resources-effectively-final +concurrency/lock-free-lazy-init +collections/stream-toarray-typed +tooling/jshell-prototyping +io/inputstream-transferto +collections/sequenced-collections +security/random-generator +language/compact-source-files +collections/immutable-set-creation +concurrency/structured-concurrency +tooling/built-in-http-server +errors/optional-chaining +language/flexible-constructor-bodies +io/reading-files +streams/stream-takewhile-dropwhile +streams/stream-of-nullable +collections/copying-collections-immutably +enterprise/singleton-ejb-vs-cdi-application-scoped +security/pem-encoding +language/default-interface-methods +io/io-class-console-io +language/records-for-data-classes +language/private-interface-methods +streams/stream-gatherers +strings/string-chars-stream +enterprise/soap-vs-jakarta-rest +enterprise/ejb-timer-vs-jakarta-scheduler +concurrency/concurrent-http-virtual +io/files-mismatch +collections/immutable-map-creation +tooling/single-file-execution +enterprise/mdb-vs-reactive-messaging +tooling/compact-object-headers +datetime/date-formatting +enterprise/ejb-vs-cdi +language/call-c-from-java +streams/optional-or +enterprise/jdbc-vs-jpa +errors/require-nonnull-else +streams/stream-iterate-predicate +strings/string-indent-transform +language/exhaustive-switch +collections/collectors-teeing +concurrency/thread-sleep-duration diff --git a/social/state.yaml b/social/state.yaml new file mode 100644 index 0000000..7691bae --- /dev/null +++ b/social/state.yaml @@ -0,0 +1,4 @@ +currentIndex: 13 +lastPostedKey: tooling/multi-file-source +lastTweetId: 2062577080253255834 +lastPostedAt: 2026-06-04T16:47:51.556293229Z diff --git a/social/tweets.yaml b/social/tweets.yaml new file mode 100644 index 0000000..7eacc4b --- /dev/null +++ b/social/tweets.yaml @@ -0,0 +1,1130 @@ +language/guarded-patterns: |- + ☕ Guarded patterns with when + + Add conditions to pattern cases using when guards. + + Nested if → when Clause (JDK 21+) + + 🔗 https://javaevolved.github.io/language/guarded-patterns.html + + #Java #JavaEvolved +datetime/math-clamp: |- + ☕ Math.clamp() + + Clamp a value between bounds with a single clear call. + + Nested min/max → Math.clamp() (JDK 21+) + + 🔗 https://javaevolved.github.io/datetime/math-clamp.html + + #Java #JavaEvolved +streams/optional-ifpresentorelse: |- + ☕ Optional.ifPresentOrElse() + + Handle both present and empty cases of Optional in one call. + + if/else on Optional → ifPresentOrElse() (JDK 9+) + + 🔗 https://javaevolved.github.io/streams/optional-ifpresentorelse.html + + #Java #JavaEvolved +strings/string-repeat: |- + ☕ String.repeat() + + Repeat a string n times without a loop. + + StringBuilder Loop → repeat() (JDK 11+) + + 🔗 https://javaevolved.github.io/strings/string-repeat.html + + #Java #JavaEvolved +language/unnamed-variables: |- + ☕ Unnamed variables with _ + + Use _ to signal intent when a variable is intentionally unused. + + Unused Variable → _ Placeholder (JDK 22+) + + 🔗 https://javaevolved.github.io/language/unnamed-variables.html + + #Java #JavaEvolved +security/strong-random: |- + ☕ Strong random generation + + Get the platform's strongest SecureRandom implementation. + + new SecureRandom() → getInstanceStrong() (JDK 9+) + + 🔗 https://javaevolved.github.io/security/strong-random.html + + #Java #JavaEvolved +io/writing-files: |- + ☕ Writing files + + Write a String to a file with one line. + + FileWriter + BufferedWriter → Files.writeString() (JDK 11+) + + 🔗 https://javaevolved.github.io/io/writing-files.html + + #Java #JavaEvolved +language/pattern-matching-instanceof: |- + ☕ Pattern matching for instanceof + + Combine type check and cast in one step with pattern matching. + + instanceof + Cast → Pattern Variable (JDK 16+) + + 🔗 https://javaevolved.github.io/language/pattern-matching-instanceof.html + + #Java #JavaEvolved +language/static-methods-in-interfaces: |- + ☕ Static methods in interfaces + + Add static utility methods directly to interfaces instead of separate utility classes. + + Utility classes → Interface static methods (JDK 8+) + + 🔗 https://javaevolved.github.io/language/static-methods-in-interfaces.html + + #Java #JavaEvolved +language/module-import-declarations: |- + ☕ Module import declarations + + Import all exported packages of a module with a single declaration. + + Many Imports → import module (JDK 25+) + + 🔗 https://javaevolved.github.io/language/module-import-declarations.html + + #Java #JavaEvolved +strings/string-strip: |- + ☕ String.strip() vs trim() + + Use Unicode-aware stripping with strip(), stripLeading(), stripTrailing(). + + trim() → strip() (JDK 11+) + + 🔗 https://javaevolved.github.io/strings/string-strip.html + + #Java #JavaEvolved +tooling/multi-file-source: |- + ☕ Multi-file source launcher + + Launch multi-file programs without an explicit compile step. + + Compile All First → Source Launcher (JDK 22+) + + 🔗 https://javaevolved.github.io/tooling/multi-file-source.html + + #Java #JavaEvolved +datetime/hex-format: |- + ☕ HexFormat + + Convert between hex strings and byte arrays with HexFormat. + + Manual Hex Conversion → HexFormat (JDK 17+) + + 🔗 https://javaevolved.github.io/datetime/hex-format.html + + #Java #JavaEvolved +language/sealed-classes: |- + ☕ Sealed classes for type hierarchies + + Restrict which classes can extend a type — enabling exhaustive switches. + + Open Hierarchy → sealed permits (JDK 17+) + + 🔗 https://javaevolved.github.io/language/sealed-classes.html + + #Java #JavaEvolved +strings/string-isblank: |- + ☕ String.isBlank() + + Check for blank strings with a single method call. + + trim().isEmpty() → isBlank() (JDK 11+) + + 🔗 https://javaevolved.github.io/strings/string-isblank.html + + #Java #JavaEvolved +enterprise/servlet-vs-jaxrs: |- + ☕ Servlet versus JAX-RS + + Replace verbose HttpServlet boilerplate with declarative JAX-RS resource classes. + + HttpServlet → JAX-RS Resource (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/servlet-vs-jaxrs.html + + #Java #JavaEvolved +enterprise/jdbc-vs-jooq: |- + ☕ JDBC versus jOOQ + + Replace raw JDBC string-based SQL with jOOQ's type-safe, fluent SQL DSL. + + Raw JDBC → jOOQ SQL DSL (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/jdbc-vs-jooq.html + + #Java #JavaEvolved +collections/reverse-list-iteration: |- + ☕ Reverse list iteration + + Iterate over a list in reverse order with a clean for-each loop. + + Manual ListIterator → reversed() (JDK 21+) + + 🔗 https://javaevolved.github.io/collections/reverse-list-iteration.html + + #Java #JavaEvolved +enterprise/jpa-vs-jakarta-data: |- + ☕ JPA versus Jakarta Data + + Declare a repository interface and let Jakarta Data generate the DAO implementation automatically. + + JPA EntityManager → Jakarta Data Repository (JDK 21+) + + 🔗 https://javaevolved.github.io/enterprise/jpa-vs-jakarta-data.html + + #Java #JavaEvolved +tooling/jfr-profiling: |- + ☕ JFR for profiling + + Profile any Java app with the built-in Flight Recorder — no external tools. + + External Profiler → Java Flight Recorder (JDK 9+) + + 🔗 https://javaevolved.github.io/tooling/jfr-profiling.html + + #Java #JavaEvolved +concurrency/stable-values: |- + ☕ Stable values + + Thread-safe lazy initialization without volatile or synchronized. + + Double-Checked Locking → StableValue (JDK 25+) + + 🔗 https://javaevolved.github.io/concurrency/stable-values.html + + #Java #JavaEvolved +enterprise/jdbc-resultset-vs-jpa-criteria: |- + ☕ JDBC ResultSet Mapping vs JPA Criteria API + + Replace manual JDBC ResultSet mapping with JPA's type-safe Criteria API for dynamic que… + + JDBC ResultSet → JPA Criteria API (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/jdbc-resultset-vs-jpa-criteria.html + + #Java #JavaEvolved +language/type-inference-with-var: |- + ☕ Type inference with var + + Use var for local variable type inference — less noise, same safety. + + Explicit Types → var keyword (JDK 10+) + + 🔗 https://javaevolved.github.io/language/type-inference-with-var.html + + #Java #JavaEvolved +streams/predicate-not: |- + ☕ Predicate.not() for negation + + Use Predicate.not() to negate method references cleanly instead of writing lambda wrappers. + + Lambda negation → Predicate.not() (JDK 11+) + + 🔗 https://javaevolved.github.io/streams/predicate-not.html + + #Java #JavaEvolved +concurrency/process-api: |- + ☕ Modern Process API + + Inspect and manage OS processes with ProcessHandle. + + Runtime.exec() → ProcessHandle (JDK 9+) + + 🔗 https://javaevolved.github.io/concurrency/process-api.html + + #Java #JavaEvolved +datetime/java-time-basics: |- + ☕ java.time API basics + + Use immutable, clear date/time types instead of Date and Calendar. + + Date + Calendar → java.time.* (JDK 8+) + + 🔗 https://javaevolved.github.io/datetime/java-time-basics.html + + #Java #JavaEvolved +language/pattern-matching-switch: |- + ☕ Pattern matching in switch + + Replace if-else instanceof chains with clean switch type patterns. + + if-else Chain → Type Patterns (JDK 21+) + + 🔗 https://javaevolved.github.io/language/pattern-matching-switch.html + + #Java #JavaEvolved +language/static-members-in-inner-classes: |- + ☕ Static members in inner classes + + Define static members in inner classes without requiring static nested… + + Must use static nested class → Static members in inner classes (JDK 16+) + + 🔗 https://javaevolved.github.io/language/static-members-in-inner-classes.html + + #Java #JavaEvolved +language/record-patterns: |- + ☕ Record patterns (destructuring) + + Destructure records directly in patterns — extract fields in one step. + + Manual Access → Destructuring (JDK 21+) + + 🔗 https://javaevolved.github.io/language/record-patterns.html + + #Java #JavaEvolved +errors/helpful-npe: |- + ☕ Helpful NullPointerExceptions + + JVM automatically tells you exactly which variable was null. + + Cryptic NPE → Detailed NPE (JDK 14+) + + 🔗 https://javaevolved.github.io/errors/helpful-npe.html + + #Java #JavaEvolved +enterprise/spring-null-safety-jspecify: |- + ☕ Spring Null Safety with JSpecify + + Spring 7 adopts JSpecify annotations, making non-null the default and reducing annota… + + Spring @NonNull/@Nullable → JSpecify @NullMarked (JDK 17+) + + 🔗 https://javaevolved.github.io/enterprise/spring-null-safety-jspecify.html + + #Java #JavaEvolved +concurrency/executor-try-with-resources: |- + ☕ ExecutorService auto-close + + Use try-with-resources for automatic executor shutdown. + + Manual Shutdown → try-with-resources (JDK 19+) + + 🔗 https://javaevolved.github.io/concurrency/executor-try-with-resources.html + + #Java #JavaEvolved +streams/stream-tolist: |- + ☕ Stream.toList() + + Terminal toList() replaces the verbose collect(Collectors.toList()). + + Collectors.toList() → .toList() (JDK 16+) + + 🔗 https://javaevolved.github.io/streams/stream-tolist.html + + #Java #JavaEvolved +strings/string-lines: |- + ☕ String.lines() for line splitting + + Use String.lines() to split text into a stream of lines without regex overhead. + + split("\\n") → lines() (JDK 11+) + + 🔗 https://javaevolved.github.io/strings/string-lines.html + + #Java #JavaEvolved +collections/map-entry-factory: |- + ☕ Map.entry() factory + + Create map entries with a clean factory method. + + SimpleEntry → Map.entry() (JDK 9+) + + 🔗 https://javaevolved.github.io/collections/map-entry-factory.html + + #Java #JavaEvolved +concurrency/scoped-values: |- + ☕ Scoped values + + Share data across call stacks safely without ThreadLocal pitfalls. + + ThreadLocal → ScopedValue (JDK 25+) + + 🔗 https://javaevolved.github.io/concurrency/scoped-values.html + + #Java #JavaEvolved +language/markdown-javadoc-comments: |- + ☕ Markdown in Javadoc comments + + Write Javadoc comments in Markdown instead of HTML for better readability. + + HTML-based Javadoc → Markdown Javadoc (JDK 23+) + + 🔗 https://javaevolved.github.io/language/markdown-javadoc-comments.html + + #Java #JavaEvolved +concurrency/completablefuture-chaining: |- + ☕ CompletableFuture chaining + + Chain async operations without blocking, using CompletableFuture. + + Blocking Future.get() → CompletableFuture (JDK 8+) + + 🔗 https://javaevolved.github.io/concurrency/completablefuture-chaining.html + + #Java #JavaEvolved +enterprise/spring-api-versioning: |- + ☕ Spring Framework 7 API Versioning + + Replace duplicated version-prefixed controllers with Spring Framework 7's native API ver… + + Manual URL Path Versioning → Native API Versioning (JDK 17+) + + 🔗 https://javaevolved.github.io/enterprise/spring-api-versioning.html + + #Java #JavaEvolved +io/http-client: |- + ☕ Modern HTTP client + + Use the built-in HttpClient for clean, modern HTTP requests. + + HttpURLConnection → HttpClient (JDK 11+) + + 🔗 https://javaevolved.github.io/io/http-client.html + + #Java #JavaEvolved +streams/stream-mapmulti: |- + ☕ Stream.mapMulti() + + Emit zero or more elements per input without creating intermediate streams. + + flatMap + List → mapMulti() (JDK 16+) + + 🔗 https://javaevolved.github.io/streams/stream-mapmulti.html + + #Java #JavaEvolved +strings/string-formatted: |- + ☕ String.formatted() + + Call formatted() on the template string itself. + + String.format() → formatted() (JDK 15+) + + 🔗 https://javaevolved.github.io/strings/string-formatted.html + + #Java #JavaEvolved +datetime/duration-and-period: |- + ☕ Duration and Period + + Calculate time differences with type-safe Duration and Period. + + Millisecond Math → Duration / Period (JDK 8+) + + 🔗 https://javaevolved.github.io/datetime/duration-and-period.html + + #Java #JavaEvolved +errors/optional-orelsethrow: |- + ☕ Optional.orElseThrow() without supplier + + Use Optional.orElseThrow() as a clearer, intent-revealing alternative to get(). + + get() or orElseThrow(supplier) → orElseThrow() (JDK 10+) + + 🔗 https://javaevolved.github.io/errors/optional-orelsethrow.html + + #Java #JavaEvolved +io/path-of: |- + ☕ Path.of() factory + + Use Path.of() — the modern factory method on the Path interface. + + Paths.get() → Path.of() (JDK 11+) + + 🔗 https://javaevolved.github.io/io/path-of.html + + #Java #JavaEvolved +collections/unmodifiable-collectors: |- + ☕ Unmodifiable collectors + + Collect directly to an unmodifiable list with stream.toList(). + + collectingAndThen → stream.toList() (JDK 16+) + + 🔗 https://javaevolved.github.io/collections/unmodifiable-collectors.html + + #Java #JavaEvolved +enterprise/manual-transaction-vs-declarative: |- + ☕ Manual JPA Transaction vs Declarative @Transactional + + Replace verbose begin/commit/rollback blocks with a single @Transactiona… + + Manual Transaction → @Transactional (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/manual-transaction-vs-declarative.html + + #Java #JavaEvolved +security/tls-default: |- + ☕ TLS 1.3 by default + + TLS 1.3 is enabled by default — no explicit protocol configuration needed. + + Manual TLS Config → TLS 1.3 Default (JDK 11+) + + 🔗 https://javaevolved.github.io/security/tls-default.html + + #Java #JavaEvolved +enterprise/jndi-lookup-vs-cdi-injection: |- + ☕ JNDI Lookup vs CDI Injection + + Replace fragile JNDI string lookups with type-safe CDI injection for container-managed resources. + + JNDI Lookup → CDI @Inject (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/jndi-lookup-vs-cdi-injection.html + + #Java #JavaEvolved +errors/null-in-switch: |- + ☕ Null case in switch + + Handle null directly as a switch case — no separate guard needed. + + Guard Before Switch → case null (JDK 21+) + + 🔗 https://javaevolved.github.io/errors/null-in-switch.html + + #Java #JavaEvolved +datetime/instant-precision: |- + ☕ Instant with nanosecond precision + + Get timestamps with microsecond or nanosecond precision. + + Milliseconds → Nanoseconds (JDK 9+) + + 🔗 https://javaevolved.github.io/datetime/instant-precision.html + + #Java #JavaEvolved +language/primitive-types-in-patterns: |- + ☕ Primitive types in patterns + + Pattern matching now works with primitive types, not just objects. + + Manual Range Checks → Primitive Patterns (JDK 25+) + + 🔗 https://javaevolved.github.io/language/primitive-types-in-patterns.html + + #Java #JavaEvolved +language/text-blocks-for-multiline-strings: |- + ☕ Text blocks for multiline strings + + Write multiline strings naturally with triple-quote text blocks. + + String Concatenation → Text Blocks (JDK 15+) + + 🔗 https://javaevolved.github.io/language/text-blocks-for-multiline-strings.html + + #Java #JavaEvolved +io/file-memory-mapping: |- + ☕ File memory mapping + + Map files larger than 2GB with deterministic cleanup using MemorySegment. + + MappedByteBuffer → MemorySegment with Arena (JDK 22+) + + 🔗 https://javaevolved.github.io/io/file-memory-mapping.html + + #Java #JavaEvolved +tooling/junit6-with-jspecify: |- + ☕ JUnit 6 with JSpecify null safety + + JUnit 6 adopts JSpecify @NullMarked, making null contracts explicit across its assertion API. + + Unannotated API → @NullMarked API (JDK 17+) + + 🔗 https://javaevolved.github.io/tooling/junit6-with-jspecify.html + + #Java #JavaEvolved +streams/virtual-thread-executor: |- + ☕ Virtual thread executor + + Use virtual thread executors for unlimited lightweight concurrency. + + Fixed Thread Pool → Virtual Thread Executor (JDK 21+) + + 🔗 https://javaevolved.github.io/streams/virtual-thread-executor.html + + #Java #JavaEvolved +language/switch-expressions: |- + ☕ Switch expressions + + Switch as an expression that returns a value — no break, no fall-through. + + Switch Statement → Switch Expression (JDK 14+) + + 🔗 https://javaevolved.github.io/language/switch-expressions.html + + #Java #JavaEvolved +errors/record-based-errors: |- + ☕ Record-based error responses + + Use records for concise, immutable error response types. + + Map or Verbose Class → Error Records (JDK 16+) + + 🔗 https://javaevolved.github.io/errors/record-based-errors.html + + #Java #JavaEvolved +streams/collectors-flatmapping: |- + ☕ Collectors.flatMapping() + + Use flatMapping() to flatten inside a grouping collector. + + Nested flatMap → flatMapping() (JDK 9+) + + 🔗 https://javaevolved.github.io/streams/collectors-flatmapping.html + + #Java #JavaEvolved +language/diamond-operator: |- + ☕ Diamond with anonymous classes + + Diamond operator now works with anonymous classes too. + + Repeat Type Args → Diamond <> (JDK 9+) + + 🔗 https://javaevolved.github.io/language/diamond-operator.html + + #Java #JavaEvolved +language/compact-canonical-constructor: |- + ☕ Compact canonical constructor + + Validate and normalize record fields without repeating parameter lists. + + Explicit constructor validation → Compact constructor (JDK 16+) + + 🔗 https://javaevolved.github.io/language/compact-canonical-constructor.html + + #Java #JavaEvolved +tooling/aot-class-preloading: |- + ☕ AOT class preloading + + Cache class loading and compilation for instant startup. + + Cold Start Every Time → AOT Cache (JDK 25+) + + 🔗 https://javaevolved.github.io/tooling/aot-class-preloading.html + + #Java #JavaEvolved +io/deserialization-filters: |- + ☕ Deserialization filters + + Restrict which classes can be deserialized to prevent attacks. + + Accept Everything → ObjectInputFilter (JDK 9+) + + 🔗 https://javaevolved.github.io/io/deserialization-filters.html + + #Java #JavaEvolved +enterprise/spring-xml-config-vs-annotations: |- + ☕ Spring XML Bean Config vs Annotation-Driven + + Replace verbose Spring XML bean definitions with concise annotation-dri… + + XML Bean Definitions → Annotation-Driven Beans (JDK 17+) + + 🔗 https://javaevolved.github.io/enterprise/spring-xml-config-vs-annotations.html + + #Java #JavaEvolved +security/key-derivation-functions: |- + ☕ Key Derivation Functions + + Derive cryptographic keys using the standard KDF API. + + Manual PBKDF2 → KDF API (JDK 25+) + + 🔗 https://javaevolved.github.io/security/key-derivation-functions.html + + #Java #JavaEvolved +concurrency/virtual-threads: |- + ☕ Virtual threads + + Create millions of lightweight virtual threads instead of heavy OS threads. + + Platform Threads → Virtual Threads (JDK 21+) + + 🔗 https://javaevolved.github.io/concurrency/virtual-threads.html + + #Java #JavaEvolved +collections/immutable-list-creation: |- + ☕ Immutable list creation + + Create immutable lists in one clean expression. + + Verbose Wrapping → List.of() (JDK 9+) + + 🔗 https://javaevolved.github.io/collections/immutable-list-creation.html + + #Java #JavaEvolved +enterprise/jsf-managed-bean-vs-cdi-named: |- + ☕ JSF Managed Bean vs CDI Named Bean + + Replace deprecated JSF @ManagedBean with CDI @Named for a unified dependency injection model. + + @ManagedBean → @Named + CDI (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/jsf-managed-bean-vs-cdi-named.html + + #Java #JavaEvolved +errors/multi-catch: |- + ☕ Multi-catch exception handling + + Catch multiple exception types in a single catch block. + + Separate Catch Blocks → Multi-catch (JDK 7+) + + 🔗 https://javaevolved.github.io/errors/multi-catch.html + + #Java #JavaEvolved +io/try-with-resources-effectively-final: |- + ☕ Try-with-resources improvement + + Use existing effectively-final variables directly in try-with-resources. + + Re-declare Variable → Effectively Final (JDK 9+) + + 🔗 https://javaevolved.github.io/io/try-with-resources-effectively-final.html + + #Java #JavaEvolved +concurrency/lock-free-lazy-init: |- + ☕ Lock-free lazy initialization + + Replace double-checked locking with StableValue for lazy singletons. + + synchronized + volatile → StableValue (JDK 25+) + + 🔗 https://javaevolved.github.io/concurrency/lock-free-lazy-init.html + + #Java #JavaEvolved +collections/stream-toarray-typed: |- + ☕ Typed stream toArray + + Filter a collection and collect the results to a typed array using a single stream expression. + + Manual Filter + Copy → toArray(generator) (JDK 8+) + + 🔗 https://javaevolved.github.io/collections/stream-toarray-typed.html + + #Java #JavaEvolved +tooling/jshell-prototyping: |- + ☕ JShell for prototyping + + Try Java expressions interactively without creating files. + + Create File + Compile + Run → jshell REPL (JDK 9+) + + 🔗 https://javaevolved.github.io/tooling/jshell-prototyping.html + + #Java #JavaEvolved +io/inputstream-transferto: |- + ☕ InputStream.transferTo() + + Copy an InputStream to an OutputStream in one call. + + Manual Copy Loop → transferTo() (JDK 9+) + + 🔗 https://javaevolved.github.io/io/inputstream-transferto.html + + #Java #JavaEvolved +collections/sequenced-collections: |- + ☕ Sequenced collections + + Access first/last elements and reverse views with clean API methods. + + Index Arithmetic → getFirst/getLast (JDK 21+) + + 🔗 https://javaevolved.github.io/collections/sequenced-collections.html + + #Java #JavaEvolved +security/random-generator: |- + ☕ RandomGenerator interface + + Use the RandomGenerator interface to choose random number algorithms by name without coupling t… + + new Random() / ThreadLocalRandom → RandomGenerator factory (JDK 17+) + + 🔗 https://javaevolved.github.io/security/random-generator.html + + #Java #JavaEvolved +language/compact-source-files: |- + ☕ Compact source files + + Write a complete program without class declaration or public static void main. + + Main Class Ceremony → void main() (JDK 25+) + + 🔗 https://javaevolved.github.io/language/compact-source-files.html + + #Java #JavaEvolved +collections/immutable-set-creation: |- + ☕ Immutable set creation + + Create immutable sets with a single factory call. + + Verbose Wrapping → Set.of() (JDK 9+) + + 🔗 https://javaevolved.github.io/collections/immutable-set-creation.html + + #Java #JavaEvolved +concurrency/structured-concurrency: |- + ☕ Structured concurrency + + Manage concurrent task lifetimes as a single unit of work. + + Manual Thread Lifecycle → StructuredTaskScope (JDK 25+) + + 🔗 https://javaevolved.github.io/concurrency/structured-concurrency.html + + #Java #JavaEvolved +tooling/built-in-http-server: |- + ☕ Built-in HTTP server + + Java 18 includes a built-in minimal HTTP server for prototyping and file serving. + + External Server / Framework → jwebserver CLI (JDK 18+) + + 🔗 https://javaevolved.github.io/tooling/built-in-http-server.html + + #Java #JavaEvolved +errors/optional-chaining: |- + ☕ Optional chaining + + Replace nested null checks with an Optional pipeline. + + Nested Null Checks → Optional Pipeline (JDK 9+) + + 🔗 https://javaevolved.github.io/errors/optional-chaining.html + + #Java #JavaEvolved +language/flexible-constructor-bodies: |- + ☕ Flexible constructor bodies + + Validate and compute values before calling super() or this(). + + Validate After super() → Code Before super() (JDK 25+) + + 🔗 https://javaevolved.github.io/language/flexible-constructor-bodies.html + + #Java #JavaEvolved +io/reading-files: |- + ☕ Reading files + + Read an entire file into a String with one line. + + BufferedReader → Files.readString() (JDK 11+) + + 🔗 https://javaevolved.github.io/io/reading-files.html + + #Java #JavaEvolved +streams/stream-takewhile-dropwhile: |- + ☕ Stream takeWhile / dropWhile + + Take or drop elements from a stream based on a predicate. + + Manual Loop → takeWhile/dropWhile (JDK 9+) + + 🔗 https://javaevolved.github.io/streams/stream-takewhile-dropwhile.html + + #Java #JavaEvolved +streams/stream-of-nullable: |- + ☕ Stream.ofNullable() + + Create a zero-or-one element stream from a nullable value. + + Null Check → ofNullable() (JDK 9+) + + 🔗 https://javaevolved.github.io/streams/stream-of-nullable.html + + #Java #JavaEvolved +collections/copying-collections-immutably: |- + ☕ Copying collections immutably + + Create an immutable copy of any collection in one call. + + Manual Copy + Wrap → List.copyOf() (JDK 10+) + + 🔗 https://javaevolved.github.io/collections/copying-collections-immutably.html + + #Java #JavaEvolved +enterprise/singleton-ejb-vs-cdi-application-scoped: |- + ☕ Singleton EJB vs CDI @ApplicationScoped + + Replace Singleton EJBs with CDI @ApplicationScoped beans for simpler shared… + + @Singleton EJB → @ApplicationScoped CDI (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/singleton-ejb-vs-cdi-application-scoped.html + + #Java #JavaEvolved +security/pem-encoding: |- + ☕ PEM encoding/decoding + + Encode and decode PEM-formatted cryptographic objects natively. + + Manual Base64 + Headers → PEM API (JDK 25+) + + 🔗 https://javaevolved.github.io/security/pem-encoding.html + + #Java #JavaEvolved +language/default-interface-methods: |- + ☕ Default interface methods + + Add method implementations directly in interfaces, enabling multiple inherita… + + Abstract classes for shared behavior → Default methods on interfaces (JDK 8+) + + 🔗 https://javaevolved.github.io/language/default-interface-methods.html + + #Java #JavaEvolved +io/io-class-console-io: |- + ☕ IO class for console I/O + + The new IO class provides simple, concise methods for console input and output. + + System.out / Scanner → IO class (JDK 25+) + + 🔗 https://javaevolved.github.io/io/io-class-console-io.html + + #Java #JavaEvolved +language/records-for-data-classes: |- + ☕ Records for data classes + + One line replaces 30+ lines of boilerplate for immutable data carriers. + + Verbose POJO → record (JDK 16+) + + 🔗 https://javaevolved.github.io/language/records-for-data-classes.html + + #Java #JavaEvolved +language/private-interface-methods: |- + ☕ Private interface methods + + Extract shared logic in interfaces using private methods. + + Duplicated Logic → Private Methods (JDK 9+) + + 🔗 https://javaevolved.github.io/language/private-interface-methods.html + + #Java #JavaEvolved +streams/stream-gatherers: |- + ☕ Stream gatherers + + Use gatherers for custom intermediate stream operations. + + Custom Collector → gather() (JDK 24+) + + 🔗 https://javaevolved.github.io/streams/stream-gatherers.html + + #Java #JavaEvolved +strings/string-chars-stream: |- + ☕ String chars as stream + + Process string characters as a stream pipeline. + + Manual Loop → chars() Stream (JDK 9+) + + 🔗 https://javaevolved.github.io/strings/string-chars-stream.html + + #Java #JavaEvolved +enterprise/soap-vs-jakarta-rest: |- + ☕ SOAP Web Services vs Jakarta REST + + Replace heavyweight SOAP/WSDL endpoints with clean Jakarta REST resources returning JSON. + + JAX-WS / SOAP → Jakarta REST / JSON (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/soap-vs-jakarta-rest.html + + #Java #JavaEvolved +enterprise/ejb-timer-vs-jakarta-scheduler: |- + ☕ EJB Timer vs Jakarta Scheduler + + Replace heavyweight EJB timers with Jakarta Concurrency's ManagedScheduledExecutor… + + EJB TimerService → ManagedScheduledExecutorService (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/ejb-timer-vs-jakarta-scheduler.html + + #Java #JavaEvolved +concurrency/concurrent-http-virtual: |- + ☕ Concurrent HTTP with virtual threads + + Fetch many URLs concurrently with virtual threads and HttpClient. + + Thread Pool + URLConnection → Virtual Threads + HttpClient (JDK 21+) + + 🔗 https://javaevolved.github.io/concurrency/concurrent-http-virtual.html + + #Java #JavaEvolved +io/files-mismatch: |- + ☕ Files.mismatch() + + Compare two files efficiently without loading them into memory. + + Manual Byte Compare → Files.mismatch() (JDK 12+) + + 🔗 https://javaevolved.github.io/io/files-mismatch.html + + #Java #JavaEvolved +collections/immutable-map-creation: |- + ☕ Immutable map creation + + Create immutable maps inline without a builder. + + Map Builder Pattern → Map.of() (JDK 9+) + + 🔗 https://javaevolved.github.io/collections/immutable-map-creation.html + + #Java #JavaEvolved +tooling/single-file-execution: |- + ☕ Single-file execution + + Run single-file Java programs directly without javac. + + Two-Step Compile → Direct Launch (JDK 11+) + + 🔗 https://javaevolved.github.io/tooling/single-file-execution.html + + #Java #JavaEvolved +enterprise/mdb-vs-reactive-messaging: |- + ☕ Message-Driven Bean vs Reactive Messaging + + Replace JMS Message-Driven Beans with MicroProfile Reactive Messaging for simpler even… + + Message-Driven Bean → Reactive Messaging (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/mdb-vs-reactive-messaging.html + + #Java #JavaEvolved +tooling/compact-object-headers: |- + ☕ Compact object headers + + Cut object header size in half for better memory density and cache usage. + + 128-bit Headers → 64-bit Headers (JDK 25+) + + 🔗 https://javaevolved.github.io/tooling/compact-object-headers.html + + #Java #JavaEvolved +datetime/date-formatting: |- + ☕ Date formatting + + Format dates with thread-safe, immutable DateTimeFormatter. + + SimpleDateFormat → DateTimeFormatter (JDK 8+) + + 🔗 https://javaevolved.github.io/datetime/date-formatting.html + + #Java #JavaEvolved +enterprise/ejb-vs-cdi: |- + ☕ EJB versus CDI + + Replace heavyweight EJBs with lightweight CDI beans for dependency injection and transactions. + + EJB → CDI Bean (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/ejb-vs-cdi.html + + #Java #JavaEvolved +language/call-c-from-java: |- + ☕ Calling out to C code from Java + + FFM lets Java call C libraries directly, without JNI boilerplate or C-side Java kn… + + JNI (Java Native Interface) → FFM (Foreign Function & Memory API) (JDK 22+) + + 🔗 https://javaevolved.github.io/language/call-c-from-java.html + + #Java #JavaEvolved +streams/optional-or: |- + ☕ Optional.or() fallback + + Chain Optional fallbacks without nested checks. + + Nested Fallback → .or() chain (JDK 9+) + + 🔗 https://javaevolved.github.io/streams/optional-or.html + + #Java #JavaEvolved +enterprise/jdbc-vs-jpa: |- + ☕ JDBC versus JPA + + Replace verbose JDBC boilerplate with JPA's object-relational mapping and EntityManager. + + JDBC → JPA EntityManager (JDK 11+) + + 🔗 https://javaevolved.github.io/enterprise/jdbc-vs-jpa.html + + #Java #JavaEvolved +errors/require-nonnull-else: |- + ☕ Objects.requireNonNullElse() + + Get a non-null value with a clear default, no ternary needed. + + Ternary Null Check → requireNonNullElse() (JDK 9+) + + 🔗 https://javaevolved.github.io/errors/require-nonnull-else.html + + #Java #JavaEvolved +streams/stream-iterate-predicate: |- + ☕ Stream.iterate() with predicate + + Use a predicate to stop iteration — like a for-loop in stream form. + + iterate + limit → iterate(seed, pred, op) (JDK 9+) + + 🔗 https://javaevolved.github.io/streams/stream-iterate-predicate.html + + #Java #JavaEvolved +strings/string-indent-transform: |- + ☕ String.indent() and transform() + + Indent text and chain string transformations fluently. + + Manual Indentation → indent() / transform() (JDK 12+) + + 🔗 https://javaevolved.github.io/strings/string-indent-transform.html + + #Java #JavaEvolved +language/exhaustive-switch: |- + ☕ Exhaustive switch without default + + Compiler verifies all sealed subtypes are covered — no default needed. + + Mandatory default → Sealed Exhaustiveness (JDK 21+) + + 🔗 https://javaevolved.github.io/language/exhaustive-switch.html + + #Java #JavaEvolved +collections/collectors-teeing: |- + ☕ Collectors.teeing() + + Compute two aggregations in a single stream pass. + + Two Passes → teeing() (JDK 12+) + + 🔗 https://javaevolved.github.io/collections/collectors-teeing.html + + #Java #JavaEvolved +concurrency/thread-sleep-duration: |- + ☕ Thread.sleep with Duration + + Use Duration for self-documenting time values. + + Milliseconds → Duration (JDK 19+) + + 🔗 https://javaevolved.github.io/concurrency/thread-sleep-duration.html + + #Java #JavaEvolved diff --git a/specs/social-posts-spec.md b/specs/social-posts-spec.md index 4fe7234..2b954a6 100644 --- a/specs/social-posts-spec.md +++ b/specs/social-posts-spec.md @@ -1,20 +1,23 @@ -# Automated Weekly Social Posts — Specification +# Automated Twice-Weekly Social Posts — Specification ## Problem -Post one pattern per week to X/Twitter and Bluesky, covering all 112 patterns (~2 years of content). Fully automated via GitHub Actions with no manual steps. +Post one pattern twice a week to X/Twitter, covering all 113+ patterns. Fully automated via GitHub Actions with no manual steps. ## Approach Use a **GitHub Actions scheduled workflow** that: -1. Reads a pre-shuffled queue file (`content/social-queue.txt`) listing all pattern keys -2. Each week, picks the next unposted pattern, posts to X and Bluesky -3. Commits the updated queue pointer back to the repo to track progress -4. When all 112 are exhausted, reshuffles and starts over +1. Reads a pre-shuffled queue file (`social/queue.txt`) listing all pattern keys +2. Each Monday and Thursday, picks the next unposted pattern, posts to X/Twitter +3. Commits the updated state back to the repo to track progress +4. When all patterns are exhausted, reshuffles and starts over ### Why a queue file? - Deterministic: you can review/reorder upcoming posts - Resumable: survives workflow failures, repo changes - Auditable: git history shows what was posted when +### Pre-drafted tweets +All tweet copy is pre-generated into `social/tweets.yaml` so it can be reviewed and edited before posting. The queue generator builds tweets from content YAML fields and validates they fit within 280 characters. + ## Post Format ``` ☕ {title} @@ -30,41 +33,50 @@ Use a **GitHub Actions scheduled workflow** that: ## Implementation -### 1. Social Queue Generator -**File:** `html-generators/generate-social-queue.java` +### 1. Queue & Tweet Generator +**File:** `html-generators/generatesocialqueue.java` + +JBang script that reads all content files, shuffles them, and writes: +- `social/queue.txt` — one `category/slug` per line (posting order) +- `social/tweets.yaml` — pre-drafted tweet text for each pattern, keyed by `category/slug` +- `social/state.yaml` — posting state (`currentIndex`, `lastPostedKey`, `lastTweetId`, `lastPostedAt`) -JBang script that reads all content files, shuffles them, and writes `content/social-queue.txt` (one `category/slug` per line). +On re-run: detects new patterns (appends to end), prunes deleted patterns, preserves existing order and manual tweet edits. Use `--reshuffle` to force a full reshuffle. -### 2. Social Post Script -**File:** `html-generators/social-post.sh` +### 2. Post Script +**File:** `html-generators/socialpost.java` -Shell script that: -- Reads the next unposted line from `content/social-queue.txt` -- Loads the pattern's JSON/YAML to build the post text -- Posts to X/Twitter via API v2 (OAuth 1.0a) -- Posts to Bluesky via AT Protocol API -- Updates the pointer file (`content/social-queue-pointer.txt`) +JBang script that: +- Reads state from `social/state.yaml` +- Looks up the pre-drafted tweet text from `social/tweets.yaml` +- Posts to X/Twitter via API v2 (OAuth 1.0a with HMAC-SHA1 signing) +- Updates state only after confirmed API success +- Supports `--dry-run` to preview without posting ### 3. GitHub Actions Workflow **File:** `.github/workflows/social-post.yml` -- Schedule: weekly cron (e.g., Tuesday 15:00 UTC) -- Runs the post script -- Commits updated queue state back to repo +- Schedule: every Monday and Thursday at 14:00 UTC (10 AM ET) +- Manual dispatch support (`workflow_dispatch`) +- Concurrency group prevents double-posts +- Commits updated state back to repo ## Required GitHub Secrets | Secret | Purpose | |--------|---------| -| `TWITTER_API_KEY` | X API v2 OAuth 1.0a consumer key | -| `TWITTER_API_SECRET` | X API v2 OAuth 1.0a consumer secret | +| `TWITTER_CONSUMER_KEY` | X API v2 OAuth 1.0a consumer key | +| `TWITTER_CONSUMER_KEY_SECRET` | X API v2 OAuth 1.0a consumer secret | | `TWITTER_ACCESS_TOKEN` | X API v2 user access token | -| `TWITTER_ACCESS_SECRET` | X API v2 user access secret | -| `BLUESKY_HANDLE` | Bluesky handle (e.g., javaevolved.bsky.social) | -| `BLUESKY_APP_PASSWORD` | Bluesky app password | +| `TWITTER_ACCESS_TOKEN_SECRET` | X API v2 user access token secret | ## Design Decisions -- **Text-only posts** with URL — platforms unfurl the OG card automatically from `og:image` meta tags +- **Twitter/X only** — Bluesky support can be added later +- **Text-only posts** with URL — platform unfurls the OG card automatically from `og:image` meta tags +- **Pre-drafted tweets** — generated into `social/tweets.yaml` for review; editable before posting - **Random order** via pre-shuffled queue for variety across categories -- **Reshuffles** when all 112 patterns are exhausted -- **Post script uses `curl`** for both APIs (no extra dependencies) -- **Queue state** tracked via a pointer file containing the current line number +- **Reshuffles** when all patterns are exhausted +- **JBang/Java for posting** — consistent with the rest of the project; safer for OAuth 1.0a signing than shell +- **State tracked** via `social/state.yaml` with `currentIndex`, `lastPostedKey`, `lastTweetId`, `lastPostedAt` +- **Social files in `social/`** (not `content/`) to avoid triggering site deploys +- **New patterns** appended to end of queue on re-run; deleted patterns pruned +- **Tweet length validation** — generator truncates summaries to fit 280 chars diff --git a/templates/index.html b/templates/index.html index 59ee7f9..3484582 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,6 +1,13 @@ + + + java.evolved Code Snippets | java.evolved @@ -169,6 +176,10 @@ + + +