This is an automated email from the ASF dual-hosted git repository.

mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp-site.git


The following commit(s) were added to refs/heads/main by this push:
     new b22b7bb7a Update of Team Site (#98)
b22b7bb7a is described below

commit b22b7bb7acc0058c2906c95d32ee60f8fca1d5f0
Author: Richard Zowalla <[email protected]>
AuthorDate: Tue May 5 20:18:44 2026 +0200

    Update of Team Site (#98)
---
 .github/workflows/main.yml                         |  10 +-
 .gitignore                                         |   1 +
 README.md                                          |  63 +++-
 pom.xml                                            | 189 ++++++++---
 src/main/java/org/apache/opennlp/website/Site.java | 352 +++++++++++++++++++++
 .../opennlp/website/contributors/Contributor.java  |  85 +++++
 .../opennlp/website/contributors/Contributors.java | 181 +++++++++++
 .../opennlp/website/contributors/Github.java       | 157 +++++++++
 .../opennlp/website/contributors/HttpCache.java    | 148 +++++++++
 .../website/contributors/IdentityIndex.java        | 175 ++++++++++
 .../opennlp/website/contributors/Roster.java       | 178 +++++++++++
 .../apache/opennlp/website/contributors/Stats.java |  47 +++
 src/main/jbake/assets/css/custom-style.css         | 113 +++++++
 src/main/jbake/assets/css/scheme-dark.css          |  40 +++
 src/main/jbake/content/team.ad                     |  68 ++--
 src/main/resources/team-overrides.properties       |  58 ++++
 16 files changed, 1776 insertions(+), 89 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 9941956a3..eb9808b61 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -29,16 +29,20 @@ jobs:
 
     steps:
       - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # 
v3.6.0
-      - name: Set up JDK 8
+      - name: Set up JDK 21
         uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # 
v3.14.1
         with:
-          java-version: '8'
+          java-version: '21'
           distribution: 'temurin'
           cache: maven
       - name: maven-settings-xml-action
         run: echo "<settings xmlns=\"http://maven.apache.org/SETTINGS/1.0.0\"; 
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"; 
xsi:schemaLocation=\"http://maven.apache.org/SETTINGS/1.0.0 
http://maven.apache.org/xsd/settings-1.0.0.xsd\";> <activeProfiles> 
<activeProfile>github</activeProfile> </activeProfiles> <profiles> <profile> 
<id>github</id> <repositories> <repository> <id>central-repo</id> 
<url>https://repo.maven.apache.org/maven2</url> <releases> 
<enabled>true</enabled> </rel [...]
+      # CI runs with -Pno-fetch: skip the live GitHub + Whimsy retrieval so 
that
+      # anonymous rate-limiting on shared GH Actions egress IPs cannot make the
+      # validation build flaky. The ASF BuildBot does the live fetch when it
+      # publishes to asf-site.
       - name: Build with Maven
-        run: mvn help:system -U -ntp --batch-mode --show-version --fail-at-end 
clean install
+        run: mvn help:system -U -ntp --batch-mode --show-version --fail-at-end 
-Pno-fetch clean install
       - name: basic validations
         run: |
           [ -f target/opennlp-site/index.html  ] && echo 'index.html exist'
diff --git a/.gitignore b/.gitignore
index e729b3880..c56f57fd7 100755
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ target
 .project
 *.log
 *.iml
+tmp
\ No newline at end of file
diff --git a/README.md b/README.md
index c59411cc0..4d5f4e4e1 100644
--- a/README.md
+++ b/README.md
@@ -24,16 +24,73 @@ Welcome to OpenNLP Site Source Code
 [![Build 
Status](https://github.com/apache/opennlp/workflows/Java%20CI/badge.svg)](https://github.com/apache/opennlp-site/actions)
 [![Stack 
Overflow](https://img.shields.io/badge/stack%20overflow-opennlp-f1eefe.svg)](https://stackoverflow.com/questions/tagged/opennlp)
 
+#### Requirements
+
+- Java 21
+- Maven 3.6.3+
+
 #### Build
 
 ```bash
 mvn clean install
 ```
 
-#### Test Site locally - starts a web server on Port 8080
+The output is rendered to `target/opennlp-site/`. Open 
`target/opennlp-site/index.html` in a browser to preview.
+
+#### Live dev mode
+
 ```bash
-mvn clean package jbake:inline -Djbake.port=8080 -Djbake.listenAddress=0.0.0.0
+mvn compile -Pserve                       # http://localhost:8080/
+mvn compile -Pserve -Djbake.port=9000     # custom port
 ```
+
+Bakes the site once, then serves `target/opennlp-site/` over HTTP and watches 
`src/main/jbake/` recursively. Any change to a content file, template, asset or 
`jbake.properties` triggers a re-bake (debounced ~400 ms); reload the browser 
to see it. Press Ctrl-C to stop.
+
+The contributor fetch (Whimsy + GitHub) runs once at startup and is reused 
across re-bakes — no re-fetch and no new rate-limit cost while you iterate. 
Cached HTTP responses live under `target/contrib-cache/`. Restart `mvn compile 
-Pserve` to refresh the contributor data.
+
+#### Live contributor data
+
+The team page (`team.html`) is populated at build time by the 
`org.apache.opennlp.website.Site` driver, which fetches:
+
+- the OpenNLP committer/PMC roster from [Whimsy LDAP 
exports](https://whimsy.apache.org/public/), and
+- live contributor + activity data from the GitHub REST API across 
`apache/opennlp`, `apache/opennlp-site`, `apache/opennlp-addons` and 
`apache/opennlp-sandbox`.
+
+It then partitions members into **Active Team** (any activity in the last 2 
years), **Emeritus** (committer/PMC with no recent activity) and a **Wall of 
Fame** (everyone else with a GitHub login — committers/PMCs aren't repeated 
here). Identity merging (login + apache id + email + normalized name) and bot 
filtering match the logic of the `opennlp-stats` reference tool.
+
+HTTP responses are cached on disk under `target/contrib-cache/` with a 6-hour 
TTL, so iterative local builds are cheap. The build runs without a GitHub 
token; anonymous rate limits (60 req/h) may leave a few `/users/{login}` 
lookups unresolved on a cold cache, which can drop a committer whose GitHub 
login differs from their Apache id into Emeritus until the cache warms. 
Re-running the build inside the TTL fills it in.
+
+##### Manual roster overrides
+
+Whimsy doesn't always carry `githubUsername` for committers, and the live 
`/users/{login}` bridge can hit anonymous rate limits, so 
`src/main/resources/team-overrides.properties` lets you pin attributes per 
Apache id. The file is read at build time and merged on top of the Whimsy + 
GitHub data.
+
+| Key | Meaning |
+| --- | --- |
+| `<apacheId>.gh` | One or more `;`-separated GitHub logins. The first is used 
for the card link and avatar; the rest are merged into the same record so their 
commits/PRs/comments roll up. |
+| `<apacheId>.status` | `active` or `emeritus`. Forces the section bucket 
regardless of what the live activity check says. |
+| `<apacheId>.chair` | `true` for the current PMC chair. Renders an extra 
orange `Chair` badge on the card and adds the chair entry to the legend. |
+
+Example:
+
+```properties
+jzemerick.gh     = jzonthemtn
+jzemerick.status = active
+jzemerick.chair  = true
+
+joern.gh         = kottmann
+joern.status     = active
+```
+
+##### Skipping the live fetch (offline / restricted CI)
+
+If the build environment can't reach `api.github.com` or `whimsy.apache.org` — 
corporate proxy, blocked CI runner, demo build — skip the retrieval entirely:
+
+```bash
+mvn compile -Pno-fetch          # Maven profile
+OPENNLP_SITE_NO_FETCH=1 mvn compile   # env var (any non-empty truthy value)
+```
+
+The team page renders with empty Active/Emeritus/Wall-of-Fame sections (each 
shows a "No contributors to show." placeholder); the rest of the site builds 
normally.
+
 #### Build Bot
 
-Website is build via ASF BuildBot. You find it [here](https://ci.apache.org/).
+Website is built via ASF BuildBot. You find it [here](https://ci.apache.org/).
diff --git a/pom.xml b/pom.xml
index 4e42b2077..87d2cb3a6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,7 +22,6 @@
 <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/maven-v4_0_0.xsd";>
   <modelVersion>4.0.0</modelVersion>
-  <packaging>pom</packaging>
 
   <groupId>org.apache.opennlp</groupId>
   <artifactId>opennlp-site</artifactId>
@@ -32,66 +31,104 @@
   <properties>
     <!-- Build Properties -->
     <jbake-core.version>2.6.7</jbake-core.version>
-    <maven.compiler.source>1.8</maven.compiler.source>
-    <maven.compiler.target>1.8</maven.compiler.target>
-    <maven.version>3.3.9</maven.version>
+    <maven.compiler.release>21</maven.compiler.release>
+    <maven.version>3.6.3</maven.version>
     <asciidoctor.version>2.5.10</asciidoctor.version>
     <freemarker.version>2.3.32</freemarker.version>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
   </properties>
 
+  <dependencies>
+    <!-- JBake itself; we drive it programmatically from Site.java -->
+    <dependency>
+      <groupId>org.jbake</groupId>
+      <artifactId>jbake-core</artifactId>
+      <version>${jbake-core.version}</version>
+      <exclusions>
+        <exclusion>
+          <groupId>org.slf4j</groupId>
+          <artifactId>jul-to-slf4j</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <!-- Freemarker templates (.ftl) -->
+    <dependency>
+      <groupId>org.freemarker</groupId>
+      <artifactId>freemarker</artifactId>
+      <version>${freemarker.version}</version>
+    </dependency>
+    <!-- AsciiDoc rendering (.ad) -->
+    <dependency>
+      <groupId>org.asciidoctor</groupId>
+      <artifactId>asciidoctorj</artifactId>
+      <version>${asciidoctor.version}</version>
+    </dependency>
+    <!-- JBake 2.6 demoted these to runtime scope, but Site.java touches the 
API directly -->
+    <dependency>
+      <groupId>commons-configuration</groupId>
+      <artifactId>commons-configuration</artifactId>
+      <version>1.10</version>
+    </dependency>
+    <!-- Pinned to 3.0.37 because JBake 2.6.7 hard-codes admin/admin to open 
the embedded
+         "cache" database. OrientDB 3.2 disabled those default credentials and 
JBake's
+         ContentStore.startup fails with OSecurityAccessException, aborting 
the bake. -->
+    <dependency>
+      <groupId>com.orientechnologies</groupId>
+      <artifactId>orientdb-core</artifactId>
+      <version>3.0.37</version>
+    </dependency>
+    <!-- OrientDB 3.0.37 transitively pulls JNA 4.5.0, which ships only 
i386/x86_64 native
+         stubs. 5.13.0 adds arm64-darwin so the build runs on Apple Silicon. 
-->
+    <dependency>
+      <groupId>net.java.dev.jna</groupId>
+      <artifactId>jna</artifactId>
+      <version>5.13.0</version>
+    </dependency>
+    <dependency>
+      <groupId>net.java.dev.jna</groupId>
+      <artifactId>jna-platform</artifactId>
+      <version>5.13.0</version>
+    </dependency>
+    <!-- JSON parsing for the GitHub + Whimsy clients in contributors/ -->
+    <dependency>
+      <groupId>org.json</groupId>
+      <artifactId>json</artifactId>
+      <version>20240303</version>
+    </dependency>
+  </dependencies>
+
   <build>
     <finalName>opennlp-site</finalName>
 
     <plugins>
       <plugin>
         <artifactId>maven-compiler-plugin</artifactId>
-        <version>3.10.1</version>
+        <version>3.13.0</version>
         <configuration>
-            <source>1.8</source>
-            <target>1.8</target>
+          <release>${maven.compiler.release}</release>
         </configuration>
-    </plugin>
+      </plugin>
       <plugin>
-        <groupId>org.jbake</groupId>
-        <artifactId>jbake-maven-plugin</artifactId>
-        <version>0.3.5</version>
-
-        <!-- dependencies -->
-        <dependencies>
-          <!-- optional : a jbake version -->
-          <dependency>
-            <groupId>org.jbake</groupId>
-            <artifactId>jbake-core</artifactId>
-            <version>${jbake-core.version}</version>
-          </dependency>
-          <!-- for freemarker templates (.ftl) -->
-          <dependency>
-            <groupId>org.freemarker</groupId>
-            <artifactId>freemarker</artifactId>
-            <version>${freemarker.version}</version>
-          </dependency>
-          <!-- for ascii doc format (.ad) -->
-          <dependency>
-            <groupId>org.asciidoctor</groupId>
-            <artifactId>asciidoctorj</artifactId>
-            <version>${asciidoctor.version}</version>
-          </dependency>
-          <!-- Overriding orientdb, required to work on Apple Silicon (M1,..) 
-->
-          <dependency>
-            <groupId>com.orientechnologies</groupId>
-            <artifactId>orientdb-core</artifactId>
-            <version>3.1.16</version>
-          </dependency>
-
-        </dependencies>
-
+        <groupId>org.codehaus.mojo</groupId>
+        <artifactId>exec-maven-plugin</artifactId>
+        <version>3.1.1</version>
         <executions>
           <execution>
-            <id>default-generate</id>
+            <id>opennlp-site</id>
             <phase>compile</phase>
             <goals>
-              <goal>generate</goal>
+              <goal>java</goal>
             </goals>
+            <configuration>
+              <includeProjectDependencies>true</includeProjectDependencies>
+              <mainClass>org.apache.opennlp.website.Site</mainClass>
+              <arguments>
+                <argument>${project.basedir}/src/main/jbake</argument>
+                
<argument>${project.build.directory}/${project.build.finalName}</argument>
+                <argument>${project.build.directory}/jbake-staged</argument>
+                <argument>${project.build.directory}/contrib-cache</argument>
+              </arguments>
+            </configuration>
           </execution>
         </executions>
       </plugin>
@@ -652,4 +689,70 @@
     </plugins>
   </build>
 
+  <profiles>
+    <!-- mvn compile -Pserve  ->  bake once, serve target/opennlp-site/ on 
http://localhost:${jbake.port}/,
+         and re-bake whenever something under src/main/jbake/ changes. -->
+    <profile>
+      <id>serve</id>
+      <properties>
+        <jbake.port>8080</jbake.port>
+      </properties>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.codehaus.mojo</groupId>
+            <artifactId>exec-maven-plugin</artifactId>
+            <executions>
+              <execution>
+                <id>opennlp-site</id>
+                <configuration>
+                  <arguments>
+                    <argument>${project.basedir}/src/main/jbake</argument>
+                    
<argument>${project.build.directory}/${project.build.finalName}</argument>
+                    
<argument>${project.build.directory}/jbake-staged</argument>
+                    
<argument>${project.build.directory}/contrib-cache</argument>
+                    <argument>--serve</argument>
+                    <argument>--port=${jbake.port}</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+
+    <!-- mvn compile -Pno-fetch  ->  skip the live GitHub + Whimsy retrieval 
entirely.
+         Use this when network egress to api.github.com / whimsy.apache.org is 
blocked
+         (corporate proxy, restricted CI runner, offline build). The team page 
renders
+         with empty Active/Emeritus/Wall-of-Fame sections; everything else 
builds
+         normally. The same can be triggered with -DopennlpSiteNoFetch=true or 
by
+         exporting OPENNLP_SITE_NO_FETCH=1 in the build environment. -->
+    <profile>
+      <id>no-fetch</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.codehaus.mojo</groupId>
+            <artifactId>exec-maven-plugin</artifactId>
+            <executions>
+              <execution>
+                <id>opennlp-site</id>
+                <configuration>
+                  <arguments>
+                    <argument>${project.basedir}/src/main/jbake</argument>
+                    
<argument>${project.build.directory}/${project.build.finalName}</argument>
+                    
<argument>${project.build.directory}/jbake-staged</argument>
+                    
<argument>${project.build.directory}/contrib-cache</argument>
+                    <argument>--no-fetch</argument>
+                  </arguments>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+
 </project>
diff --git a/src/main/java/org/apache/opennlp/website/Site.java 
b/src/main/java/org/apache/opennlp/website/Site.java
new file mode 100644
index 000000000..6e1a91497
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/Site.java
@@ -0,0 +1,352 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.opennlp.website.contributors.Contributor;
+import org.apache.opennlp.website.contributors.Contributors;
+import org.jbake.app.Oven;
+import org.jbake.app.configuration.JBakeConfiguration;
+import org.jbake.app.configuration.JBakeConfigurationFactory;
+
+import java.io.File;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+/** Build driver: fetches live contributor data, stages the JBake tree, then 
bakes. */
+public final class Site {
+
+    private static final DateTimeFormatter STAMP_FORMAT =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'", 
Locale.ROOT).withZone(ZoneOffset.UTC);
+
+    public static void main(final String[] args) throws Exception {
+        if (args.length < 4) {
+            throw new IllegalArgumentException(
+                    "usage: Site <source> <dest> <staged> <cache> [--serve] 
[--port=N] [--no-fetch]");
+        }
+        final Path source = Path.of(args[0]);
+        final Path dest = Path.of(args[1]);
+        final Path staged = Path.of(args[2]);
+        final Path cacheDir = Path.of(args[3]);
+
+        boolean serve = false;
+        boolean noFetch = false;
+        int port = 8080;
+        for (int i = 4; i < args.length; i++) {
+            final String a = args[i];
+            if (a.equals("--serve")) serve = true;
+            else if (a.equals("--no-fetch")) noFetch = true;
+            else if (a.startsWith("--port=")) port = 
Integer.parseInt(a.substring("--port=".length()));
+        }
+        // Env-var fallback so CI can flip the switch without touching pom 
args.
+        if (!noFetch) {
+            final String env = System.getenv("OPENNLP_SITE_NO_FETCH");
+            noFetch = env != null && (env.equals("1") || 
env.equalsIgnoreCase("true"));
+        }
+
+        final Contributors.Sections sections;
+        if (noFetch) {
+            System.out.println("[site] --no-fetch: skipping live GitHub + 
Whimsy retrieval");
+            sections = Contributors.empty();
+        } else {
+            System.out.println("[site] fetching contributor data...");
+            sections = Contributors.load(cacheDir);
+        }
+        System.out.printf("[site] active=%d emeritus=%d wall-of-fame=%d%n",
+                sections.active().size(), sections.emeritus().size(), 
sections.wallOfFame().size());
+
+        bake(source, dest, staged, sections);
+
+        if (serve) {
+            startServer(dest, port);
+            watchAndRebake(source, dest, staged, sections);
+        }
+    }
+
+    private static void bake(
+            final Path source, final Path dest, final Path staged, final 
Contributors.Sections sections)
+            throws Exception {
+        System.out.println("[site] staging JBake source tree to " + staged);
+        rsync(source, staged);
+
+        final Path teamFile = staged.resolve("content/team.ad");
+        if (Files.exists(teamFile)) {
+            final String stamp = STAMP_FORMAT.format(Instant.now());
+            final String original = Files.readString(teamFile, 
StandardCharsets.UTF_8);
+            final String body = original
+                    .replace("<!-- ACTIVE -->", grid(sections.active()))
+                    .replace("<!-- EMERITUS -->", grid(sections.emeritus()))
+                    .replace("<!-- WALL_OF_FAME -->", 
grid(sections.wallOfFame()))
+                    .replace("<!-- LAST_UPDATED -->", lastUpdatedBlock(stamp));
+            Files.writeString(teamFile, body, StandardCharsets.UTF_8);
+        } else {
+            System.err.println("[site] WARN: " + teamFile + " not found; 
skipping contributor injection");
+        }
+
+        Files.createDirectories(dest);
+        System.out.println("[site] baking JBake site to " + dest);
+        final JBakeConfiguration config = new JBakeConfigurationFactory()
+                .createDefaultJbakeConfiguration(staged.toFile(), 
dest.toFile(), false);
+        new Oven(config).bake();
+        System.out.println("[site] done.");
+    }
+
+    /* ---------------- Live dev mode (--serve) ---------------- */
+
+    private static void startServer(final Path dest, final int port) throws 
Exception {
+        final HttpServer server = HttpServer.create(new 
InetSocketAddress(port), 0);
+        server.createContext("/", new StaticHandler(dest));
+        server.setExecutor(null);
+        server.start();
+        System.out.printf("[site] serving %s on http://localhost:%d/  (Ctrl-C 
to stop)%n", dest, port);
+    }
+
+    private static void watchAndRebake(
+            final Path source, final Path dest, final Path staged, final 
Contributors.Sections sections)
+            throws Exception {
+        try (WatchService ws = source.getFileSystem().newWatchService()) {
+            registerRecursive(source, ws);
+            long lastBakeMs = 0;
+            while (true) {
+                final WatchKey key = ws.poll(500, TimeUnit.MILLISECONDS);
+                if (key == null) continue;
+                boolean relevant = false;
+                for (final WatchEvent<?> ev : key.pollEvents()) {
+                    if (ev.kind() == StandardWatchEventKinds.OVERFLOW) 
continue;
+                    relevant = true;
+                    if (ev.kind() == StandardWatchEventKinds.ENTRY_CREATE) {
+                        final Object ctx = ev.context();
+                        final Path watchedDir = (Path) key.watchable();
+                        final Path child = ctx instanceof Path p ? 
watchedDir.resolve(p) : null;
+                        if (child != null && Files.isDirectory(child)) {
+                            registerRecursive(child, ws);
+                        }
+                    }
+                }
+                key.reset();
+                if (!relevant) continue;
+                final long now = System.currentTimeMillis();
+                if (now - lastBakeMs < 400) continue; // debounce burst writes
+                lastBakeMs = now;
+                System.out.println("[site] change detected, re-baking...");
+                try {
+                    bake(source, dest, staged, sections);
+                } catch (final Exception e) {
+                    System.err.println("[site] re-bake failed: " + 
e.getMessage());
+                }
+            }
+        }
+    }
+
+    private static void registerRecursive(final Path root, final WatchService 
ws) throws Exception {
+        Files.walkFileTree(root, new SimpleFileVisitor<>() {
+            @Override
+            public FileVisitResult preVisitDirectory(final Path dir, final 
BasicFileAttributes attrs)
+                    throws java.io.IOException {
+                dir.register(ws,
+                        StandardWatchEventKinds.ENTRY_CREATE,
+                        StandardWatchEventKinds.ENTRY_MODIFY,
+                        StandardWatchEventKinds.ENTRY_DELETE);
+                return FileVisitResult.CONTINUE;
+            }
+        });
+    }
+
+    private static final class StaticHandler implements HttpHandler {
+        private static final Map<String, String> MIME = Map.ofEntries(
+                Map.entry(".html", "text/html; charset=utf-8"),
+                Map.entry(".css", "text/css; charset=utf-8"),
+                Map.entry(".js", "application/javascript; charset=utf-8"),
+                Map.entry(".json", "application/json; charset=utf-8"),
+                Map.entry(".xml", "application/xml; charset=utf-8"),
+                Map.entry(".svg", "image/svg+xml"),
+                Map.entry(".png", "image/png"),
+                Map.entry(".jpg", "image/jpeg"),
+                Map.entry(".jpeg", "image/jpeg"),
+                Map.entry(".gif", "image/gif"),
+                Map.entry(".ico", "image/x-icon"),
+                Map.entry(".woff", "font/woff"),
+                Map.entry(".woff2", "font/woff2"),
+                Map.entry(".ttf", "font/ttf"),
+                Map.entry(".pdf", "application/pdf"));
+
+        private final Path root;
+
+        StaticHandler(final Path root) {
+            this.root = root;
+        }
+
+        @Override
+        public void handle(final HttpExchange ex) throws java.io.IOException {
+            final String requested = ex.getRequestURI().getPath();
+            final String path = requested.endsWith("/") ? requested + 
"index.html" : requested;
+            // strip leading "/" and resolve safely under root
+            final Path resolved = root.resolve(path.substring(1)).normalize();
+            if (!resolved.startsWith(root) || !Files.exists(resolved) || 
Files.isDirectory(resolved)) {
+                final byte[] body = ("404 - " + 
path).getBytes(StandardCharsets.UTF_8);
+                ex.sendResponseHeaders(404, body.length);
+                try (OutputStream os = ex.getResponseBody()) {
+                    os.write(body);
+                }
+                return;
+            }
+            final String name = resolved.getFileName().toString();
+            final int dot = name.lastIndexOf('.');
+            final String ext = dot >= 0 ? 
name.substring(dot).toLowerCase(Locale.ROOT) : "";
+            final String mime = MIME.getOrDefault(ext, 
"application/octet-stream");
+            final byte[] body = Files.readAllBytes(resolved);
+            ex.getResponseHeaders().set("Content-Type", mime);
+            ex.getResponseHeaders().set("Cache-Control", "no-store");
+            ex.sendResponseHeaders(200, body.length);
+            try (OutputStream os = ex.getResponseBody()) {
+                os.write(body);
+            }
+        }
+    }
+
+    private static String grid(final List<Contributor> people) {
+        final StringBuilder sb = new StringBuilder();
+        sb.append("++++\n");
+        sb.append("<div class=\"contributor-grid\">\n");
+        if (people.isEmpty()) {
+            sb.append("  <p class=\"contributor-empty\">No contributors to 
show.</p>\n");
+        } else {
+            final String[] palette = {
+                    "#3a1c71", "#6a3093", "#1f4068", "#2c5364", "#283c86", 
"#0f2027",
+                    "#5614b0", "#11324d", "#16222a", "#373b44", "#1d2671", 
"#3b1f5b"};
+            for (final Contributor c : people) {
+                final String name = c.displayName();
+                final String url = c.profileUrl();
+                final String tag = openTag(url);
+                final String close = url == null ? "</span>" : "</a>";
+                final String initials = initials(name);
+                final String color = palette[Math.floorMod(name.hashCode(), 
palette.length)];
+                final String roleBadge = roleBadge(c);
+                sb.append("  ").append(tag)
+                        .append("<span class=\"contributor-badge\" 
style=\"background:").append(color)
+                        .append(";\" 
aria-hidden=\"true\">").append(escape(initials)).append("</span>")
+                        .append("<span 
class=\"contributor-name\">").append(escape(name)).append("</span>")
+                        .append(roleBadge)
+                        .append(close).append("\n");
+            }
+        }
+        sb.append("</div>\n");
+        sb.append("++++\n");
+        return sb.toString();
+    }
+
+    private static String lastUpdatedBlock(final String stamp) {
+        return "++++\n"
+                + "<div class=\"team-meta\">\n"
+                + "  <p class=\"team-last-updated\">Last updated: <time 
datetime=\""
+                + escape(Instant.now().toString()) + "\">"
+                + escape(stamp) + "</time></p>\n"
+                + "  <p class=\"contributor-legend\">"
+                + "<span class=\"contributor-role\">C</span> indicates a 
committer, "
+                + "<span class=\"contributor-role\">C-P</span> a PMC member, 
and "
+                + "<span class=\"contributor-role 
contributor-role-chair\">Chair</span> the current PMC chair."
+                + "</p>\n"
+                + "</div>\n"
+                + "++++\n";
+    }
+
+    private static String openTag(final String url) {
+        if (url == null) return "<span class=\"contributor-card\">";
+        return "<a class=\"contributor-card\" href=\"" + escape(url)
+                + "\" rel=\"noopener\" target=\"_blank\">";
+    }
+
+    private static String roleBadge(final Contributor c) {
+        final StringBuilder sb = new StringBuilder();
+        final String flags = c.roleFlags();
+        if (!flags.isEmpty()) {
+            sb.append("<span 
class=\"contributor-role\">").append(flags).append("</span>");
+        }
+        if (c.isChair()) {
+            sb.append("<span class=\"contributor-role 
contributor-role-chair\">Chair</span>");
+        }
+        return sb.toString();
+    }
+
+    private static String initials(final String name) {
+        final String[] parts = name.trim().split("\\s+");
+        if (parts.length >= 2) {
+            return ("" + parts[0].charAt(0) + parts[parts.length - 
1].charAt(0))
+                    .toUpperCase(Locale.ROOT);
+        }
+        if (name.length() >= 2) {
+            return name.substring(0, 2).toUpperCase(Locale.ROOT);
+        }
+        return name.toUpperCase(Locale.ROOT);
+    }
+
+    private static String escape(final String s) {
+        if (s == null) return "";
+        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", 
"&gt;").replace("\"", "&quot;");
+    }
+
+    private static void rsync(final Path from, final Path to) throws Exception 
{
+        if (Files.exists(to)) {
+            try (Stream<Path> walk = Files.walk(to)) {
+                walk.sorted(Comparator.reverseOrder())
+                        .map(Path::toFile)
+                        .forEach(File::delete);
+            }
+        }
+        Files.createDirectories(to);
+        try (Stream<Path> walk = Files.walk(from)) {
+            walk.forEach(src -> {
+                try {
+                    final Path rel = from.relativize(src);
+                    final Path target = to.resolve(rel);
+                    if (Files.isDirectory(src)) {
+                        Files.createDirectories(target);
+                    } else {
+                        Files.createDirectories(target.getParent());
+                        Files.copy(src, target, 
StandardCopyOption.REPLACE_EXISTING);
+                    }
+                } catch (final Exception e) {
+                    throw new RuntimeException(e);
+                }
+            });
+        }
+    }
+
+    private Site() {}
+}
diff --git 
a/src/main/java/org/apache/opennlp/website/contributors/Contributor.java 
b/src/main/java/org/apache/opennlp/website/contributors/Contributor.java
new file mode 100644
index 000000000..3bd1dcdc7
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/contributors/Contributor.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website.contributors;
+
+import java.util.HashSet;
+import java.util.Set;
+
+public final class Contributor {
+    private String name;
+    private String ghLogin;
+    private String apacheId;
+    private String avatarUrl;
+    private boolean pmc;
+    private boolean committer;
+    private boolean chair;
+    private Roster.ForcedStatus forcedStatus;
+    private final Stats stats = new Stats();
+    private final Set<String> emails = new HashSet<>();
+    private final Set<String> aliases = new HashSet<>();
+
+    public String name() { return name; }
+    public String ghLogin() { return ghLogin; }
+    public String apacheId() { return apacheId; }
+    public String avatarUrl() { return avatarUrl; }
+    public boolean isPmc() { return pmc; }
+    public boolean isCommitter() { return committer; }
+    public boolean isChair() { return chair; }
+    public Roster.ForcedStatus forcedStatus() { return forcedStatus; }
+    public Stats stats() { return stats; }
+    public Set<String> emails() { return emails; }
+    public Set<String> aliases() { return aliases; }
+
+    public void setName(final String name) { this.name = name; }
+    public void setGhLogin(final String ghLogin) { this.ghLogin = ghLogin; }
+    public void setApacheId(final String apacheId) { this.apacheId = apacheId; 
}
+    public void setAvatarUrl(final String avatarUrl) { this.avatarUrl = 
avatarUrl; }
+    public void setPmc(final boolean pmc) { this.pmc = pmc; }
+    public void setCommitter(final boolean committer) { this.committer = 
committer; }
+    public void setChair(final boolean chair) { this.chair = chair; }
+    public void setForcedStatus(final Roster.ForcedStatus forcedStatus) { 
this.forcedStatus = forcedStatus; }
+
+    public String displayName() {
+        if (name != null && !name.isBlank()) return name;
+        if (ghLogin != null && !ghLogin.isBlank()) return ghLogin;
+        if (apacheId != null && !apacheId.isBlank()) return apacheId;
+        return "(unknown)";
+    }
+
+    /** Sort key: "lastname firstname" — used for stable, deterministic 
ordering. */
+    public String sortKey() {
+        final String n = displayName().trim();
+        if (n.isEmpty()) return "";
+        final int lastSpace = n.lastIndexOf(' ');
+        if (lastSpace < 0) return n.toLowerCase(java.util.Locale.ROOT);
+        final String last = n.substring(lastSpace + 1);
+        final String first = n.substring(0, lastSpace);
+        return (last + " " + first).toLowerCase(java.util.Locale.ROOT);
+    }
+
+    public String profileUrl() {
+        return ghLogin != null && !ghLogin.isBlank()
+                ? "https://github.com/"; + ghLogin
+                : null;
+    }
+
+    public String roleFlags() {
+        if (pmc) return "C-P";
+        if (committer) return "C";
+        return "";
+    }
+}
diff --git 
a/src/main/java/org/apache/opennlp/website/contributors/Contributors.java 
b/src/main/java/org/apache/opennlp/website/contributors/Contributors.java
new file mode 100644
index 000000000..d9edb3461
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/contributors/Contributors.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website.contributors;
+
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class Contributors {
+
+    public static final int ACTIVE_WINDOW_YEARS = 2;
+
+    public record Sections(List<Contributor> active, List<Contributor> 
emeritus, List<Contributor> wallOfFame) {}
+
+    /** No-network fallback used by --no-fetch / OPENNLP_SITE_NO_FETCH. */
+    public static Sections empty() {
+        return new Sections(List.of(), List.of(), List.of());
+    }
+
+    public static Sections load(final Path cacheDir) throws Exception {
+        final String token = System.getenv("GITHUB_TOKEN");
+        final HttpCache cache = new HttpCache(cacheDir, Duration.ofHours(6), 
token);
+        final Instant cutoff = LocalDate.now(ZoneOffset.UTC)
+                .minusYears(ACTIVE_WINDOW_YEARS)
+                .atStartOfDay()
+                .toInstant(ZoneOffset.UTC);
+
+        final IdentityIndex idx = new IdentityIndex();
+
+        // 1) Seed from the ASF roster so PMC + committers appear even if 
inactive on GitHub.
+        final List<Roster.Member> roster = Roster.fetch(cache);
+        for (final Roster.Member m : roster) {
+            final Contributor c = idx.findOrCreate(m.primaryGhLogin(), 
m.apacheId, null, m.name);
+            if (c == null) continue;
+            if (m.pmc) c.setPmc(true);
+            if (m.committer) c.setCommitter(true);
+            if (m.chair) c.setChair(true);
+            if (m.forcedStatus != null) c.setForcedStatus(m.forcedStatus);
+            // Members with multiple GH accounts: link the secondary logins as 
aliases
+            // so future events on those logins merge into this record.
+            for (int i = 1; i < m.ghLogins.size(); i++) {
+                idx.linkLoginAlias(c, m.ghLogins.get(i));
+            }
+        }
+
+        // 2) Pull live data per repo, but defer the merge until after we 
resolve display
+        //    names: Whimsy rarely carries githubUsername for ASF committers, 
so we have to
+        //    bridge login <-> roster-name via /users/{login}.name.
+        final Github gh = new Github(cache);
+        final Set<String> logins = new LinkedHashSet<>();
+        record Touch(String login, Instant ts) {}
+        record AvatarCommits(String login, String avatar, int commits) {}
+        final List<AvatarCommits> contribRows = new ArrayList<>();
+        final List<Touch> touches = new ArrayList<>();
+
+        for (final String repo : Github.REPOS) {
+            ingest(() -> gh.contributors(repo), row -> {
+                logins.add(row.login());
+                contribRows.add(new AvatarCommits(row.login(), 
row.avatarUrl(), row.commits()));
+            });
+            ingest(() -> gh.prsOpenedSince(repo, cutoff), e -> {
+                logins.add(e.login());
+                touches.add(new Touch(e.login(), e.timestamp()));
+            });
+            ingest(() -> gh.issueCommentsSince(repo, cutoff), e -> {
+                logins.add(e.login());
+                touches.add(new Touch(e.login(), e.timestamp()));
+            });
+            ingest(() -> gh.reviewCommentsSince(repo, cutoff), e -> {
+                logins.add(e.login());
+                touches.add(new Touch(e.login(), e.timestamp()));
+            });
+            ingest(() -> gh.commitsSince(repo, cutoff), e -> {
+                logins.add(e.login());
+                touches.add(new Touch(e.login(), e.timestamp()));
+            });
+        }
+
+        // 3) Resolve display name per login (cached), then merge into the 
index by login + name.
+        final Map<String, String> loginToName = new HashMap<>();
+        for (final String login : logins) {
+            final String name = gh.userName(login);
+            if (name != null) loginToName.put(login, name);
+        }
+
+        // Pass apacheId=login only when the index already has a roster entry 
for that id,
+        // so the GH event merges into that committer record (very common for 
ASF
+        // committers whose GH login matches their apache_id — rzo1, mawiesne, 
joern, ...).
+        for (final AvatarCommits row : contribRows) {
+            final String apId = idx.hasApacheId(row.login()) ? row.login() : 
null;
+            final Contributor c = idx.findOrCreate(row.login(), apId, null, 
loginToName.get(row.login()));
+            if (c == null) continue;
+            if (c.avatarUrl() == null) c.setAvatarUrl(row.avatar());
+            for (int i = 0; i < row.commits(); i++) c.stats().touch(null);
+        }
+        for (final Touch t : touches) {
+            final String apId = idx.hasApacheId(t.login()) ? t.login() : null;
+            final Contributor c = idx.findOrCreate(t.login(), apId, null, 
loginToName.get(t.login()));
+            if (c == null) continue;
+            c.stats().touch(t.ts());
+        }
+
+        final List<Contributor> all = idx.all();
+
+        final List<Contributor> active = new ArrayList<>();
+        final List<Contributor> emeritus = new ArrayList<>();
+        for (final Contributor c : all) {
+            if (!c.isCommitter() && !c.isPmc()) continue;
+            final Roster.ForcedStatus forced = c.forcedStatus();
+            if (forced == Roster.ForcedStatus.ACTIVE) {
+                active.add(c);
+                continue;
+            }
+            if (forced == Roster.ForcedStatus.EMERITUS) {
+                emeritus.add(c);
+                continue;
+            }
+            final Instant last = c.stats().lastActivity();
+            if (last != null && !last.isBefore(cutoff)) {
+                active.add(c);
+            } else {
+                emeritus.add(c);
+            }
+        }
+        // Deterministic order across all sections: "Lastname, Firstname".
+        final Comparator<Contributor> byLastName = 
Comparator.comparing(Contributor::sortKey);
+        active.sort(byLastName);
+        emeritus.sort(byLastName);
+
+        final List<Contributor> wallOfFame = new ArrayList<>();
+        for (final Contributor c : all) {
+            // Committers + PMC members are already shown in Active or 
Emeritus; skip them here.
+            if (c.isCommitter() || c.isPmc()) continue;
+            if (c.ghLogin() == null && c.apacheId() == null) continue;
+            wallOfFame.add(c);
+        }
+        wallOfFame.sort(byLastName);
+
+        return new Sections(active, emeritus, wallOfFame);
+    }
+
+    @FunctionalInterface
+    private interface Fetcher<T> { List<T> get() throws Exception; }
+
+    @FunctionalInterface
+    private interface RowSink<T> { void accept(T row); }
+
+    private static <T> void ingest(final Fetcher<T> fetcher, final RowSink<T> 
sink) {
+        try {
+            for (final T row : fetcher.get()) sink.accept(row);
+        } catch (final Exception e) {
+            System.err.println("[contributors] fetch failed: " + e.getMessage()
+                    + " — continuing with partial data");
+        }
+    }
+
+    private Contributors() {}
+}
diff --git a/src/main/java/org/apache/opennlp/website/contributors/Github.java 
b/src/main/java/org/apache/opennlp/website/contributors/Github.java
new file mode 100644
index 000000000..82f4a87c6
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/contributors/Github.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website.contributors;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+public final class Github {
+
+    public static final List<String> REPOS = List.of(
+            "apache/opennlp",
+            "apache/opennlp-site",
+            "apache/opennlp-addons",
+            "apache/opennlp-sandbox");
+
+    private final HttpCache cache;
+
+    public Github(final HttpCache cache) {
+        this.cache = cache;
+    }
+
+    public record ContributorRow(String login, String avatarUrl, int commits) 
{}
+
+    public record EventRow(String login, Instant timestamp) {}
+
+    public List<ContributorRow> contributors(final String repoFull) throws 
Exception {
+        final List<ContributorRow> out = new ArrayList<>();
+        paginate("https://api.github.com/repos/"; + repoFull + 
"/contributors?per_page=100",
+                arr -> {
+                    for (int i = 0; i < arr.length(); i++) {
+                        final JSONObject c = arr.getJSONObject(i);
+                        final String login = c.optString("login", null);
+                        final String avatar = c.optString("avatar_url", null);
+                        final int commits = c.optInt("contributions", 0);
+                        if (login == null || login.isBlank()) continue;
+                        if (IdentityIndex.isBot(login)) continue;
+                        out.add(new ContributorRow(login, avatar, commits));
+                    }
+                });
+        return out;
+    }
+
+    /** PR-opened events since cutoff (issues + PRs combined endpoint, 
filtered to PRs). */
+    public List<EventRow> prsOpenedSince(final String repoFull, final Instant 
since) throws Exception {
+        final List<EventRow> out = new ArrayList<>();
+        final String url = "https://api.github.com/repos/"; + repoFull
+                + "/issues?state=all&per_page=100&since=" + since.toString();
+        paginate(url, arr -> {
+            for (int i = 0; i < arr.length(); i++) {
+                final JSONObject item = arr.getJSONObject(i);
+                if (!item.has("pull_request")) continue;
+                final JSONObject user = item.optJSONObject("user");
+                final String login = user != null ? user.optString("login", 
null) : null;
+                final Instant ts = parseTs(item.optString("created_at", null));
+                if (login == null || ts == null) continue;
+                out.add(new EventRow(login, ts));
+            }
+        });
+        return out;
+    }
+
+    public List<EventRow> issueCommentsSince(final String repoFull, final 
Instant since) throws Exception {
+        return commentsSince(repoFull, "/issues/comments", since);
+    }
+
+    public List<EventRow> reviewCommentsSince(final String repoFull, final 
Instant since) throws Exception {
+        return commentsSince(repoFull, "/pulls/comments", since);
+    }
+
+    private List<EventRow> commentsSince(final String repoFull, final String 
path, final Instant since)
+            throws Exception {
+        final List<EventRow> out = new ArrayList<>();
+        final String url = "https://api.github.com/repos/"; + repoFull
+                + path + "?per_page=100&since=" + since.toString();
+        paginate(url, arr -> {
+            for (int i = 0; i < arr.length(); i++) {
+                final JSONObject item = arr.getJSONObject(i);
+                final JSONObject user = item.optJSONObject("user");
+                final String login = user != null ? user.optString("login", 
null) : null;
+                final Instant ts = parseTs(item.optString("created_at", null));
+                if (login == null || ts == null) continue;
+                out.add(new EventRow(login, ts));
+            }
+        });
+        return out;
+    }
+
+    /** Returns the display name (`name` field) from /users/{login}, or null 
on 404. */
+    public String userName(final String login) {
+        if (login == null || login.isBlank() || IdentityIndex.isBot(login)) 
return null;
+        try {
+            final String body = cache.fetch("https://api.github.com/users/"; + 
login);
+            final JSONObject obj = new JSONObject(body);
+            final String name = obj.optString("name", null);
+            return (name == null || name.isBlank()) ? null : name;
+        } catch (final Exception e) {
+            return null;
+        }
+    }
+
+    public List<EventRow> commitsSince(final String repoFull, final Instant 
since) throws Exception {
+        final List<EventRow> out = new ArrayList<>();
+        final String url = "https://api.github.com/repos/"; + repoFull
+                + "/commits?per_page=100&since=" + since.toString();
+        paginate(url, arr -> {
+            for (int i = 0; i < arr.length(); i++) {
+                final JSONObject item = arr.getJSONObject(i);
+                final JSONObject author = item.optJSONObject("author");
+                final String login = author != null ? 
author.optString("login", null) : null;
+                final JSONObject commit = item.optJSONObject("commit");
+                final JSONObject commitAuthor = commit != null ? 
commit.optJSONObject("author") : null;
+                final Instant ts = commitAuthor != null ? 
parseTs(commitAuthor.optString("date", null)) : null;
+                if (login == null || ts == null) continue;
+                out.add(new EventRow(login, ts));
+            }
+        });
+        return out;
+    }
+
+    private void paginate(final String startUrl, final Consumer<JSONArray> 
consumer) throws Exception {
+        String url = startUrl;
+        while (url != null) {
+            final HttpCache.Page page = cache.fetchPage(url);
+            final JSONArray arr = new JSONArray(page.body());
+            consumer.accept(arr);
+            url = page.nextUrl();
+            if (arr.isEmpty()) break;
+        }
+    }
+
+    private static Instant parseTs(final String s) {
+        try {
+            return s == null ? null : Instant.parse(s);
+        } catch (final Exception e) {
+            return null;
+        }
+    }
+}
diff --git 
a/src/main/java/org/apache/opennlp/website/contributors/HttpCache.java 
b/src/main/java/org/apache/opennlp/website/contributors/HttpCache.java
new file mode 100644
index 000000000..11d926cc8
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/contributors/HttpCache.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website.contributors;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+
+/** TTL-keyed disk cache for HTTP GETs; falls back to stale data on transient 
errors. */
+public final class HttpCache {
+
+    private final Path dir;
+    private final HttpClient client;
+    private final Duration ttl;
+    private final String token;
+
+    public HttpCache(final Path dir, final Duration ttl, final String token) 
throws IOException {
+        this.dir = dir;
+        this.ttl = ttl;
+        this.token = token;
+        Files.createDirectories(dir);
+        this.client = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofSeconds(15))
+                .followRedirects(HttpClient.Redirect.NORMAL)
+                .build();
+    }
+
+    public String fetch(final String url) throws IOException, 
InterruptedException {
+        final Path file = dir.resolve(hash(url) + ".json");
+        if (Files.exists(file)) {
+            final long ageMs = System.currentTimeMillis() - 
Files.getLastModifiedTime(file).toMillis();
+            if (ageMs < ttl.toMillis()) {
+                return Files.readString(file, StandardCharsets.UTF_8);
+            }
+        }
+        final HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
+                .header("Accept", "application/vnd.github+json, 
application/json")
+                .header("X-GitHub-Api-Version", "2022-11-28")
+                .header("User-Agent", "opennlp-site-build")
+                .timeout(Duration.ofSeconds(45));
+        if (token != null && !token.isBlank() && 
url.startsWith("https://api.github.com/";)) {
+            req.header("Authorization", "Bearer " + token);
+        }
+        final HttpResponse<String> resp = client.send(req.build(), 
HttpResponse.BodyHandlers.ofString());
+        if (resp.statusCode() / 100 != 2) {
+            // Fall back to stale cache rather than fail the build
+            if (Files.exists(file)) {
+                System.err.println("[http-cache] " + url + " -> " + 
resp.statusCode()
+                        + ", serving stale cache");
+                return Files.readString(file, StandardCharsets.UTF_8);
+            }
+            throw new IOException("GET " + url + " -> " + resp.statusCode() + 
": " + truncate(resp.body()));
+        }
+        Files.writeString(file, resp.body(), StandardCharsets.UTF_8);
+        return resp.body();
+    }
+
+    /** Returns the response body and the parsed Link-header `next` URL (if 
any). */
+    public Page fetchPage(final String url) throws IOException, 
InterruptedException {
+        final Path file = dir.resolve(hash(url) + ".json");
+        final Path linkFile = dir.resolve(hash(url) + ".link");
+        if (Files.exists(file)) {
+            final long ageMs = System.currentTimeMillis() - 
Files.getLastModifiedTime(file).toMillis();
+            if (ageMs < ttl.toMillis()) {
+                final String body = Files.readString(file, 
StandardCharsets.UTF_8);
+                final String next = Files.exists(linkFile) ? 
Files.readString(linkFile).trim() : "";
+                return new Page(body, next.isEmpty() ? null : next);
+            }
+        }
+        final HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
+                .header("Accept", "application/vnd.github+json")
+                .header("X-GitHub-Api-Version", "2022-11-28")
+                .header("User-Agent", "opennlp-site-build")
+                .timeout(Duration.ofSeconds(45));
+        if (token != null && !token.isBlank() && 
url.startsWith("https://api.github.com/";)) {
+            req.header("Authorization", "Bearer " + token);
+        }
+        final HttpResponse<String> resp = client.send(req.build(), 
HttpResponse.BodyHandlers.ofString());
+        if (resp.statusCode() / 100 != 2) {
+            if (Files.exists(file)) {
+                System.err.println("[http-cache] " + url + " -> " + 
resp.statusCode()
+                        + ", serving stale cache");
+                final String body = Files.readString(file, 
StandardCharsets.UTF_8);
+                final String next = Files.exists(linkFile) ? 
Files.readString(linkFile).trim() : "";
+                return new Page(body, next.isEmpty() ? null : next);
+            }
+            throw new IOException("GET " + url + " -> " + resp.statusCode() + 
": " + truncate(resp.body()));
+        }
+        Files.writeString(file, resp.body(), StandardCharsets.UTF_8);
+        final Optional<String> link = resp.headers().firstValue("link");
+        final String next = link.map(HttpCache::parseNextLink).orElse(null);
+        Files.writeString(linkFile, next == null ? "" : next, 
StandardCharsets.UTF_8);
+        return new Page(resp.body(), next);
+    }
+
+    private static String parseNextLink(final String header) {
+        for (final String part : header.split(",")) {
+            final List<String> bits = List.of(part.split(";"));
+            if (bits.size() < 2) continue;
+            final String rel = bits.get(1).trim();
+            if (rel.equals("rel=\"next\"")) {
+                final String url = bits.get(0).trim();
+                if (url.startsWith("<") && url.endsWith(">")) return 
url.substring(1, url.length() - 1);
+            }
+        }
+        return null;
+    }
+
+    private static String hash(final String s) {
+        try {
+            final MessageDigest md = MessageDigest.getInstance("SHA-256");
+            return 
HexFormat.of().formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8))).substring(0,
 24);
+        } catch (final Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private static String truncate(final String body) {
+        if (body == null) return "";
+        return body.length() > 240 ? body.substring(0, 240) + "..." : body;
+    }
+
+    public record Page(String body, String nextUrl) {}
+}
diff --git 
a/src/main/java/org/apache/opennlp/website/contributors/IdentityIndex.java 
b/src/main/java/org/apache/opennlp/website/contributors/IdentityIndex.java
new file mode 100644
index 000000000..5ef581e63
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/contributors/IdentityIndex.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website.contributors;
+
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Merges identities across multiple sources (ASF roster, GitHub commits, 
GitHub events).
+ * Ported from opennlp-stats/aggregate.py: walks login -> apache-id -> email 
-> normalized-name
+ * keys to find an existing record, otherwise creates one.
+ */
+public final class IdentityIndex {
+
+    private static final Set<String> BOT_LOGINS = Set.of(
+            "dependabot[bot]", "github-actions[bot]", "actions-user", 
"buildbot",
+            "renovate[bot]", "codecov-commenter", "copilot", 
"copilot-swe-agent[bot]");
+
+    private final List<Contributor> records = new ArrayList<>();
+    private final Map<String, Contributor> byLogin = new HashMap<>();
+    private final Map<String, Contributor> byApacheId = new HashMap<>();
+    private final Map<String, Contributor> byEmail = new HashMap<>();
+    private final Map<String, Contributor> byName = new HashMap<>();
+
+    /** Returns true if some seeded record exists under this apache id 
(read-only). */
+    public boolean hasApacheId(final String apacheId) {
+        return apacheId != null && 
byApacheId.containsKey(apacheId.toLowerCase(Locale.ROOT));
+    }
+
+    /**
+     * Registers an additional GitHub login as an alias of an existing 
contributor.
+     * Used for people with multiple GH accounts: future events keyed by the 
alias
+     * resolve to the same record without overwriting the primary login.
+     */
+    public void linkLoginAlias(final Contributor c, final String login) {
+        if (c == null || login == null) return;
+        final String trimmed = login.trim();
+        if (trimmed.isEmpty() || isBot(trimmed)) return;
+        byLogin.put(trimmed.toLowerCase(Locale.ROOT), c);
+    }
+
+    public List<Contributor> all() {
+        // de-dupe by reference, preserving first-seen order
+        final Set<Contributor> seen = new LinkedHashSet<>(records);
+        return new ArrayList<>(seen);
+    }
+
+    /** Looks up an existing record by any matching key, otherwise creates 
one. Merges as needed. */
+    public Contributor findOrCreate(
+            final String loginIn, final String apacheId, final String email, 
final String name) {
+        final String sanitized = sanitizeLogin(loginIn);
+        if (sanitized != null && isBot(sanitized)) {
+            return null;
+        }
+        // Recover login from <login>@users.noreply.github.com
+        final String login = sanitized != null ? sanitized
+                : (email != null ? loginFromNoreply(email) : null);
+
+        final List<Contributor> candidates = new ArrayList<>();
+        if (login != null) addIfPresent(candidates, 
byLogin.get(login.toLowerCase(Locale.ROOT)));
+        if (apacheId != null) addIfPresent(candidates, 
byApacheId.get(apacheId.toLowerCase(Locale.ROOT)));
+        if (email != null) addIfPresent(candidates, 
byEmail.get(email.toLowerCase(Locale.ROOT)));
+        if (name != null) addIfPresent(candidates, 
byName.get(normalizeName(name)));
+
+        final Contributor c;
+        if (candidates.isEmpty()) {
+            c = new Contributor();
+            records.add(c);
+        } else {
+            c = candidates.get(0);
+            for (int i = 1; i < candidates.size(); i++) {
+                merge(c, candidates.get(i));
+            }
+        }
+        if (login != null && c.ghLogin() == null) c.setGhLogin(login);
+        if (apacheId != null && c.apacheId() == null) c.setApacheId(apacheId);
+        if (name != null && (c.name() == null || c.name().isBlank())) 
c.setName(name);
+        if (email != null) c.emails().add(email.toLowerCase(Locale.ROOT));
+        if (name != null) c.aliases().add(name);
+        link(c);
+        return c;
+    }
+
+    private void merge(final Contributor into, final Contributor other) {
+        if (into == other) return;
+        if (into.ghLogin() == null) into.setGhLogin(other.ghLogin());
+        if (into.apacheId() == null) into.setApacheId(other.apacheId());
+        if (into.name() == null || into.name().isBlank()) 
into.setName(other.name());
+        if (into.avatarUrl() == null) into.setAvatarUrl(other.avatarUrl());
+        if (other.isPmc()) into.setPmc(true);
+        if (other.isCommitter()) into.setCommitter(true);
+        if (other.isChair()) into.setChair(true);
+        if (into.forcedStatus() == null) 
into.setForcedStatus(other.forcedStatus());
+        into.stats().add(other.stats());
+        into.emails().addAll(other.emails());
+        into.aliases().addAll(other.aliases());
+
+        records.remove(other);
+        for (final Map<String, Contributor> table :
+                Arrays.<Map<String, Contributor>>asList(byLogin, byApacheId, 
byEmail, byName)) {
+            for (final Map.Entry<String, Contributor> e : new 
HashMap<>(table).entrySet()) {
+                if (e.getValue() == other) table.put(e.getKey(), into);
+            }
+        }
+    }
+
+    private void link(final Contributor c) {
+        if (c.ghLogin() != null) 
byLogin.put(c.ghLogin().toLowerCase(Locale.ROOT), c);
+        if (c.apacheId() != null) 
byApacheId.put(c.apacheId().toLowerCase(Locale.ROOT), c);
+        if (c.name() != null) {
+            final String n = normalizeName(c.name());
+            if (!n.isBlank()) byName.put(n, c);
+        }
+        for (final String alias : c.aliases()) {
+            final String n = normalizeName(alias);
+            if (!n.isBlank()) byName.putIfAbsent(n, c);
+        }
+        for (final String email : c.emails()) {
+            byEmail.put(email, c);
+        }
+    }
+
+    private static void addIfPresent(final List<Contributor> list, final 
Contributor c) {
+        if (c != null && !list.contains(c)) list.add(c);
+    }
+
+    private static String sanitizeLogin(final String login) {
+        if (login == null) return null;
+        final String trimmed = login.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+
+    public static boolean isBot(final String login) {
+        if (login == null) return false;
+        final String lower = login.toLowerCase(Locale.ROOT);
+        return lower.endsWith("[bot]") || BOT_LOGINS.contains(lower);
+    }
+
+    private static String loginFromNoreply(final String email) {
+        if (email == null) return null;
+        final String lower = email.toLowerCase(Locale.ROOT);
+        if (!lower.endsWith("@users.noreply.github.com")) return null;
+        final String local = lower.substring(0, lower.indexOf('@'));
+        final int plus = local.indexOf('+');
+        return plus >= 0 ? local.substring(plus + 1) : local;
+    }
+
+    private static String normalizeName(final String name) {
+        if (name == null) return "";
+        final String stripped = Normalizer.normalize(name, Normalizer.Form.NFD)
+                .replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
+        return stripped.toLowerCase(Locale.ROOT).trim().replaceAll("\\s+", " 
");
+    }
+}
diff --git a/src/main/java/org/apache/opennlp/website/contributors/Roster.java 
b/src/main/java/org/apache/opennlp/website/contributors/Roster.java
new file mode 100644
index 000000000..ee5c8ebb7
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/contributors/Roster.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website.contributors;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Pulls the OpenNLP PMC + committer roster from Whimsy LDAP exports.
+ * `public_ldap_projects.json` lists `members` (committers) and `owners` (PMC) 
per project;
+ * `public_ldap_people.json` maps each apache id to a real name and 
(sometimes) a github login.
+ */
+public final class Roster {
+
+    private static final String PROJECTS_URL = 
"https://whimsy.apache.org/public/public_ldap_projects.json";;
+    private static final String PEOPLE_URL = 
"https://whimsy.apache.org/public/public_ldap_people.json";;
+    private static final String PROJECT_KEY = "opennlp";
+
+    /** `null` when the bucket should be derived from live activity. */
+    public enum ForcedStatus { ACTIVE, EMERITUS }
+
+    public static final class Member {
+        public final String name;
+        public final String apacheId;
+        /** Ordered list of GitHub logins; index 0 is the primary used for 
display. Empty if unknown. */
+        public final List<String> ghLogins;
+        public final boolean pmc;
+        public final boolean committer;
+        public final boolean chair;
+        public final ForcedStatus forcedStatus;
+
+        Member(final String name, final String apacheId, final List<String> 
ghLogins,
+               final boolean pmc, final boolean committer, final boolean chair,
+               final ForcedStatus forcedStatus) {
+            this.name = name;
+            this.apacheId = apacheId;
+            this.ghLogins = ghLogins;
+            this.pmc = pmc;
+            this.committer = committer;
+            this.chair = chair;
+            this.forcedStatus = forcedStatus;
+        }
+
+        public String primaryGhLogin() {
+            return ghLogins.isEmpty() ? null : ghLogins.get(0);
+        }
+    }
+
+    public static List<Member> fetch(final HttpCache cache) {
+        try {
+            final JSONObject projects = new 
JSONObject(cache.fetch(PROJECTS_URL));
+            final JSONObject project = 
projects.getJSONObject("projects").optJSONObject(PROJECT_KEY);
+            if (project == null) {
+                throw new IllegalStateException("project '" + PROJECT_KEY + "' 
not in " + PROJECTS_URL);
+            }
+            final Set<String> committerIds = jsonArrayToSet(project, 
"members");
+            final Set<String> pmcIds = jsonArrayToSet(project, "owners");
+            final Set<String> all = new TreeSet<>();
+            all.addAll(committerIds);
+            all.addAll(pmcIds);
+
+            final JSONObject people = new 
JSONObject(cache.fetch(PEOPLE_URL)).optJSONObject("people");
+            final Properties overrides = loadOverrides();
+
+            final List<Member> members = new ArrayList<>();
+            for (final String id : all) {
+                final JSONObject info = people != null ? 
people.optJSONObject(id) : null;
+                final String name = info != null ? info.optString("name", id) 
: id;
+                // Override > Whimsy githubUsername > github.com link in 
Whimsy urls.
+                final List<String> ghLogins;
+                final String overrideGh = overrides.getProperty(id + ".gh");
+                if (overrideGh != null && !overrideGh.isBlank()) {
+                    ghLogins = parseLoginList(overrideGh);
+                } else {
+                    final String declaredGh = info != null ? 
info.optString("githubUsername", null) : null;
+                    final String single = (declaredGh != null && 
!declaredGh.isBlank())
+                            ? declaredGh : githubLoginFromUrls(info);
+                    ghLogins = single == null ? List.of() : List.of(single);
+                }
+                final ForcedStatus forced = 
parseStatus(overrides.getProperty(id + ".status"));
+                final boolean chair = Boolean.parseBoolean(
+                        overrides.getProperty(id + ".chair", "false").trim());
+                members.add(new Member(name, id, ghLogins, 
pmcIds.contains(id), true, chair, forced));
+            }
+            return members;
+        } catch (final Exception e) {
+            System.err.println("[roster] failed to fetch Whimsy roster: " + 
e.getMessage()
+                    + " — Active/Emeritus sections will be empty");
+            return List.of();
+        }
+    }
+
+    private static List<String> parseLoginList(final String value) {
+        final List<String> out = new ArrayList<>();
+        for (final String raw : value.split(";")) {
+            final String trimmed = raw.trim();
+            if (!trimmed.isEmpty()) out.add(trimmed);
+        }
+        return out;
+    }
+
+    private static ForcedStatus parseStatus(final String value) {
+        if (value == null) return null;
+        final String lower = value.trim().toLowerCase(java.util.Locale.ROOT);
+        if (lower.isEmpty()) return null;
+        if (lower.equals("active")) return ForcedStatus.ACTIVE;
+        if (lower.equals("emeritus")) return ForcedStatus.EMERITUS;
+        System.err.println("[roster] team-overrides: unknown status '" + value
+                + "' (expected active|emeritus); ignoring");
+        return null;
+    }
+
+    /** Loads the apache-id -> override map shipped on the classpath. */
+    private static Properties loadOverrides() {
+        final Properties props = new Properties();
+        try (InputStream in = 
Roster.class.getResourceAsStream("/team-overrides.properties")) {
+            if (in != null) props.load(in);
+        } catch (final Exception e) {
+            System.err.println("[roster] failed to load 
team-overrides.properties: " + e.getMessage());
+        }
+        return props;
+    }
+
+    /** Some Whimsy people entries list a GitHub profile URL but no 
`githubUsername`. */
+    private static String githubLoginFromUrls(final JSONObject info) {
+        if (info == null || !info.has("urls")) return null;
+        final JSONArray urls = info.optJSONArray("urls");
+        if (urls == null) return null;
+        for (int i = 0; i < urls.length(); i++) {
+            final String url = urls.optString(i, "");
+            final String host = "https://github.com/";;
+            final int idx = url.indexOf(host);
+            if (idx < 0) continue;
+            final String tail = url.substring(idx + host.length());
+            // strip trailing path/query/fragment
+            int cut = tail.length();
+            for (int c = 0; c < tail.length(); c++) {
+                final char ch = tail.charAt(c);
+                if (ch == '/' || ch == '?' || ch == '#') { cut = c; break; }
+            }
+            final String login = tail.substring(0, cut);
+            if (!login.isBlank()) return login;
+        }
+        return null;
+    }
+
+    private static Set<String> jsonArrayToSet(final JSONObject obj, final 
String key) {
+        final Set<String> out = new HashSet<>();
+        if (!obj.has(key) || obj.isNull(key)) return out;
+        final JSONArray arr = obj.getJSONArray(key);
+        for (int i = 0; i < arr.length(); i++) out.add(arr.getString(i));
+        return out;
+    }
+
+    private Roster() {}
+}
diff --git a/src/main/java/org/apache/opennlp/website/contributors/Stats.java 
b/src/main/java/org/apache/opennlp/website/contributors/Stats.java
new file mode 100644
index 000000000..1e47f00ab
--- /dev/null
+++ b/src/main/java/org/apache/opennlp/website/contributors/Stats.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.opennlp.website.contributors;
+
+import java.time.Instant;
+
+public final class Stats {
+    private int contributions;
+    private Instant lastActivity;
+
+    public void touch(final Instant ts) {
+        contributions++;
+        if (ts != null && (lastActivity == null || ts.isAfter(lastActivity))) {
+            lastActivity = ts;
+        }
+    }
+
+    public void add(final Stats other) {
+        contributions += other.contributions;
+        if (other.lastActivity != null
+                && (lastActivity == null || 
other.lastActivity.isAfter(lastActivity))) {
+            lastActivity = other.lastActivity;
+        }
+    }
+
+    public int contributions() {
+        return contributions;
+    }
+
+    public Instant lastActivity() {
+        return lastActivity;
+    }
+}
diff --git a/src/main/jbake/assets/css/custom-style.css 
b/src/main/jbake/assets/css/custom-style.css
index 02e010475..80f9b6629 100644
--- a/src/main/jbake/assets/css/custom-style.css
+++ b/src/main/jbake/assets/css/custom-style.css
@@ -148,4 +148,117 @@ div.qlist.qanda > ol > li {
     #forkongithub {
         display: none;
     }
+}
+
+/* Contributor card grid (Active / Emeritus / Wall of Fame on team.html)
+-------------------------------------------------- */
+.contributor-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+    gap: 12px;
+    list-style: none;
+    padding: 0;
+    margin: 0 0 1.5em 0;
+}
+
+.contributor-card {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 10px 12px;
+    border: 1px solid #d0d0d0;
+    border-radius: 6px;
+    background: #fff;
+    text-decoration: none;
+    color: inherit;
+    transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 
0.15s ease;
+}
+
+.contributor-card:hover,
+.contributor-card:focus {
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+    border-color: #5614b0;
+    text-decoration: none;
+    transform: translateY(-1px);
+}
+
+.contributor-badge {
+    flex: 0 0 auto;
+    width: 40px;
+    height: 40px;
+    border-radius: 50%;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    font-weight: 600;
+    font-size: 0.95em;
+    letter-spacing: 0.5px;
+}
+
+.contributor-name {
+    flex: 1 1 auto;
+    color: #333;
+    font-size: 1em;
+    font-weight: 500;
+    word-break: break-word;
+}
+
+.contributor-role {
+    flex: 0 0 auto;
+    font-size: 0.75em;
+    font-weight: 600;
+    color: #5614b0;
+    border: 1px solid #5614b0;
+    border-radius: 3px;
+    padding: 1px 5px;
+    letter-spacing: 0.5px;
+}
+
+.contributor-role-chair {
+    color: #fff;
+    background: #f59523;
+    border-color: #f59523;
+    letter-spacing: 0.3px;
+}
+
+.contributor-empty {
+    grid-column: 1 / -1;
+    color: #777;
+    font-style: italic;
+    margin: 0;
+}
+
+.contributor-legend {
+    color: #555;
+    font-size: 0.85em;
+    margin: 0;
+    display: inline-flex;
+    flex-wrap: wrap;
+    gap: 0.4em;
+    align-items: center;
+}
+
+.contributor-legend .contributor-role {
+    display: inline-flex;
+    align-items: center;
+}
+
+.team-meta {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 0.5em 1.5em;
+    margin: 0 0 1.5em 0;
+}
+
+.team-last-updated {
+    color: #777;
+    font-size: 0.85em;
+    margin: 0;
+}
+
+.team-last-updated time {
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    color: #555;
 }
\ No newline at end of file
diff --git a/src/main/jbake/assets/css/scheme-dark.css 
b/src/main/jbake/assets/css/scheme-dark.css
index 6782680db..4cccdfe94 100644
--- a/src/main/jbake/assets/css/scheme-dark.css
+++ b/src/main/jbake/assets/css/scheme-dark.css
@@ -95,4 +95,44 @@
         color: #f59523;
         background-color: #444;
     }
+
+    /* Contributor cards (team page) */
+    .contributor-card {
+        background: #2c2c2c;
+        border-color: #3a3a3a;
+        color: #eee;
+    }
+    .contributor-card:hover,
+    .contributor-card:focus {
+        border-color: #A09fff;
+        background: #333;
+        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
+    }
+    .contributor-name {
+        color: #eee;
+    }
+    .contributor-role {
+        color: #c8c4ff;
+        border-color: #A09fff;
+    }
+    .contributor-role-chair {
+        color: #1a1a1a;
+        background: #f59523;
+        border-color: #f59523;
+    }
+    .contributor-badge {
+        box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
+    }
+    .contributor-empty {
+        color: #aaa;
+    }
+    .team-last-updated {
+        color: #aaa;
+    }
+    .team-last-updated time {
+        color: #ccc;
+    }
+    .contributor-legend {
+        color: #bbb;
+    }
 }
\ No newline at end of file
diff --git a/src/main/jbake/content/team.ad b/src/main/jbake/content/team.ad
index e70d03cab..932a8f66f 100644
--- a/src/main/jbake/content/team.ad
+++ b/src/main/jbake/content/team.ad
@@ -14,52 +14,40 @@
    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    KIND, either express or implied.  See the License for the
    specific language governing permissions and limitations
-   under the License.   
+   under the License.
 ////
-= Apache OpenNLP project team
+= Apache OpenNLP Project Team
 :jbake-type: page
-:jbake-tags: maven
+:jbake-tags: team
 :jbake-status: published
 :idprefix:
 
-The OpenNLP team currently consists of:
+<!-- LAST_UPDATED -->
 
-* Jörn Kottmann (joern) (C-P)
-* Grant Ingersoll (gsingers) (C-P)
-* Isabel Drost (isabel) (P)
-* James Kosin (jkosin) (C-P)
-* Jason Baldridge (jbaldrid) (C-P)
-* Thomas Morton (tsmorton) (C-P)
-* William Silva (colen) (C-P)
-* Rodrigo Agerri (ragerri) (C-P)
-* Aliaksandr Autayeu (autayeu) ( C )
-* Boris Galitsky (bgalitsky) ( C )
-* Mark Giaconia ( C ) 
-* Tommaso Teofili (tommaso) (C-P)
-* Vinh Khuc (vkhuc) ( C )
-* Anthony Beylerian (beylerian) ( C )
-* Mondher Bouazizi (mondher) ( C )
-* Chris Mattmann (mattmann) ( C )
-* Anastasija Mensikova (anastasijam) ( C )
-* Suneel Marthi (smarthi) (C-P)
-* Daniel Russ (druss) (C-P)
-* Peter Thygesen (thygesen) ( C-P )
-* Koji Sekiguchi (koji) ( C-P )
-* Bruno P. Kinoshita (kinow) (C-P)
-* Jeff Zemerick (jzemerick) **Chair** ( C-P )
-* Richard Zowalla (rzo1) ( C-P )
-* Martin Wiesner (mawiesne) ( C-P )
-* Atita Arora (atarora) ( C-P )
-* Nishant Shrivastava (shrivnis) ( C )
+== Active Team
 
-These people contributed to OpenNLP:
+The following people are currently active on Apache OpenNLP.
 
-* Sean Adams
-* Thilo Goetz (twgoetz)
-* Gann Bierner
-* Eric Friedman
-* Joao Cavalcanti
-* Hyosup Shim
+<!-- ACTIVE -->
 
-*C* indicates a committer and *P* a PMC member.
- 
+== Emeritus
+
+Open source contribution is voluntary, and priorities, jobs and life 
circumstances change.
+The people below have served Apache OpenNLP in the past as committers or PMC 
members and
+are not currently active on the project. Their merit doesn't expire, and 
they're warmly
+welcome to return to active contribution at any time; a short note to the dev 
or private
+list is enough.
+
+<!-- EMERITUS -->
+
+== Wall of Fame -- Thanks to our Contributors
+
+Apache OpenNLP exists because of the people below, who have contributed code, 
reviews,
+documentation and bug reports across 
https://github.com/apache/opennlp[opennlp],
+https://github.com/apache/opennlp-site[opennlp-site],
+https://github.com/apache/opennlp-addons[opennlp-addons] and
+https://github.com/apache/opennlp-sandbox[opennlp-sandbox]. The list is 
generated
+automatically from GitHub: if you've contributed and don't see yourself, 
please ping us
+on the dev@ mailing list.
+
+<!-- WALL_OF_FAME -->
diff --git a/src/main/resources/team-overrides.properties 
b/src/main/resources/team-overrides.properties
new file mode 100644
index 000000000..2c5ac00f3
--- /dev/null
+++ b/src/main/resources/team-overrides.properties
@@ -0,0 +1,58 @@
+# Manual overrides for the OpenNLP roster on the team page.
+#
+# Whimsy LDAP rarely carries `githubUsername` for ASF committers, so the team
+# page's identity merge cannot reliably bridge an apache_id to a GitHub login 
on
+# its own. The Site driver also falls back to /users/{login}.name, but that 
hits
+# the anonymous GitHub rate limit (60/h) on cold-cache builds, which can leave
+# a few active members listed under Emeritus.
+#
+# Pin both the GitHub login *and* the active/emeritus bucket here so the result
+# is deterministic regardless of how the live fetch went.
+#
+# Keys are flat properties; one entry per (apache-id, attribute) pair.
+#
+#   <apacheId>.gh      = <login>[;<login>...]    # GitHub login override(s)
+#   <apacheId>.status  = active | emeritus       # forces the section bucket
+#   <apacheId>.chair   = true                    # marks the current PMC chair
+#
+# `.gh` may list multiple `;`-separated logins for people who contribute under
+# more than one GitHub account; the first one is used for the card link and
+# avatar, the rest are merged into the same record so their activity (commits,
+# PRs, comments, review comments) and Wall-of-Fame contribution counts roll up.
+#
+# Status forces win over the live activity check: if you mark someone `active`
+# they appear in Active even when the GitHub fetch sees no recent activity, and
+# vice versa for `emeritus`.
+#
+# Whitespace and lines starting with # are ignored.
+
+aarora.gh        = atarora
+aarora.status    = active
+
+anastasijam.gh   = amensiko
+anastasijam.status = emeritus
+
+colen.gh         = wcolen
+colen.status     = emeritus
+
+druss.gh         = danielruss
+druss.status     = emeritus
+
+joern.gh         = kottmann
+joern.status     = active
+
+jzemerick.gh     = jzonthemtn
+jzemerick.status = active
+jzemerick.chair  = true
+
+koji.gh          = kojisekig
+koji.status      = emeritus
+
+mattmann.gh      = chrismattmann
+mattmann.status  = emeritus
+
+tommaso.gh       = tteofili
+tommaso.status   = emeritus
+
+tallison.gh      = tballison
+tallison.status  = emeritus


Reply via email to