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
[](https://github.com/apache/opennlp-site/actions)
[](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("&", "&").replace("<", "<").replace(">",
">").replace("\"", """);
+ }
+
+ 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