Compare commits

...

3 Commits

Author SHA1 Message Date
acb061baca chore(deploy): build rust binaries in a podman container
Workstation runs Fedora 44 (glibc 2.43); servers are still on F42 and
F43. A native release build produces ELFs the older glibc can't load
(GLIBC_2.43 not found), and the api/worker units fail-loop on every
deploy. Build inside docker.io/library/rust:1-bookworm (glibc 2.36)
so the artifacts are forward-compatible with every Fedora target.
Output goes to target/deploy/ to keep separate from native dev
builds, and the cargo registry/git index are cached in named podman
volumes so subsequent builds are incremental. podman is a hard
requirement; no docker fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:05:13 +03:00
8a7177a54a feat(ui): render GFM and embedded HTML in project READMEs
ReactMarkdown was running with no plugins, so README headers full of
raw <div align=center>, tables, <details>/<summary>, and other GFM
markup rendered as escaped text. Wire in remark-gfm for tables and
GFM features, rehype-raw for embedded HTML, and rehype-sanitize with
an extended schema that permits README-typical tags and attributes
(align, target, width/height, picture/source, etc.) while still
blocking script/iframe/object — READMEs come from external repos so
they need adversarial-input handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:05:05 +03:00
818a535903 feat(worker): capture commits on non-default branches and forks
The ingestion paths each had a gap that let non-default-branch work
slip through: /search/commits silently excludes forks, the per-repo
REST commit scan only walked the default branch, and the user events
feed ages out after 90 days. Catch them by enumerating branches per
repo and scanning each (with per-branch state cursors so a brand-new
branch isn't cut off by the default branch's cursor), pre-filtering
branches via a GraphQL HEAD-author check so big upstream forks like
azure-docs don't trigger hundreds of wasted REST calls, treating
GitHub's HTTP 500 on author-filtered empty branches as "no commits"
rather than a server error, and adding fork:true to the search query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:04:58 +03:00
8 changed files with 707 additions and 37 deletions

1
Cargo.lock generated
View File

@@ -1307,6 +1307,7 @@ dependencies = [
"chrono",
"moments-core",
"moments-entities",
"percent-encoding",
"reqwest",
"serde",
"serde_json",

View File

@@ -17,3 +17,4 @@ tracing.workspace = true
async-trait.workspace = true
reqwest.workspace = true
serde.workspace = true
percent-encoding = "2"

View File

@@ -7,12 +7,17 @@
//! to, opened issues/PRs on, or reviewed, even without collaborator
//! status. No result cap (cursor-paginated).
//!
//! Then walks each repo's commit history via
//! `/repos/{owner}/{repo}/commits?author={user}` with a `since` cursor
//! to avoid re-fetching known commits.
//! Then walks each branch's commit history via
//! `/repos/{owner}/{repo}/commits?author={user}&sha={branch}` with a
//! per-branch `since` cursor to avoid re-fetching known commits. Walking
//! every branch (not just the default) is what catches work-in-progress
//! on feature branches and pushes to fork branches that never get merged
//! upstream — neither the user events feed nor /search/commits surface
//! those reliably.
//!
//! Events use `github-commit:{sha}` as their ID, matching the scheme in
//! `github_search`, so duplicates are resolved via idempotent upsert.
//! `github_search`, so duplicates are resolved via idempotent upsert
//! (the same commit reached via two branches just upserts twice).
use std::collections::HashSet;
use std::sync::Arc;
@@ -21,10 +26,30 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
use moments_entities::{Event, RepoLanguage, Source};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use reqwest::{Client, header};
use serde_json::Value;
use tracing::{debug, warn};
/// Encode characters that have meaning in a URL query — branch names can
/// contain `/`, `#`, `?`, etc. Whitelisting is too fragile; encode anything
/// outside the unreserved set plus a few safe characters.
const BRANCH_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'&')
.add(b'=')
.add(b'+')
.add(b'%');
const SOURCE_NAME: &str = "github-repo";
const USER_AGENT: &str = concat!(
"moments/",
@@ -227,19 +252,217 @@ impl GithubRepoSource {
Ok(repos)
}
/// Fetch commits for a single repo, paginating fully on first run
/// and using `since` on subsequent runs to catch everything new.
/// Branch discovery via GraphQL, filtered to branches whose HEAD
/// commit was authored by the user. Skips the long tail of
/// upstream-contributor branches in large forks (e.g. azure-docs).
///
/// Why HEAD author and not `history(author:).totalCount`: the latter
/// forces GraphQL to walk full commit history per branch looking for
/// matches, which times out (502) on forks with thousands of branches.
/// Checking the HEAD commit's author is O(1) per branch. The blind
/// spot — branches with the user's older commits but a different
/// HEAD author — is rare in practice for forks/feature branches.
///
/// On any GraphQL failure, callers should fall back to `list_branches`
/// (REST, walks everything; 500s from empty branches are silenced
/// inside `scan_repo_branch`).
async fn list_branches_with_commits(
&self,
repo: &Repo,
user_login: &str,
) -> Result<Vec<String>, SourceError> {
let token = match &self.config.token {
Some(t) => t,
None => return Err(SourceError::Http("no token; graphql unavailable".into())),
};
let parts: Vec<&str> = repo.full_name.splitn(2, '/').collect();
if parts.len() != 2 {
return Ok(Vec::new());
}
let (owner, name) = (parts[0], parts[1]);
let mut branches = Vec::new();
let mut cursor: Option<String> = None;
// Cap pages to bound cost on pathological repos. 50 pages × 100
// branches = 5000; well past anything plausible for a human user.
for _ in 0..50u32 {
let after = match &cursor {
Some(c) => format!(", after: \"{}\"", c),
None => String::new(),
};
// `author.user.login` resolves the commit's GitHub user (may
// differ from the raw commit author name); falling back to
// `author.email` is intentionally omitted to keep the query
// shape minimal — false negatives there are caught by the
// REST fallback on the next poll cycle.
let query = format!(
r#"{{ repository(owner: "{owner}", name: "{name}") {{ refs(refPrefix: "refs/heads/", first: 100{after}) {{ pageInfo {{ hasNextPage endCursor }} nodes {{ name target {{ ... on Commit {{ author {{ user {{ login }} }} }} }} }} }} }} }}"#,
);
let body = serde_json::json!({ "query": query });
let resp = self
.client
.post("https://api.github.com/graphql")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::USER_AGENT, USER_AGENT)
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(SourceError::Http(format!(
"{} POST graphql (branches {}/{})",
resp.status(),
owner,
name
)));
}
let data: Value = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
if let Some(errors) = data.get("errors").and_then(Value::as_array) {
if let Some(msg) = errors.first().and_then(|e| e.get("message")).and_then(Value::as_str) {
return Err(SourceError::Http(format!("GraphQL error listing branches: {msg}")));
}
}
let refs = &data["data"]["repository"]["refs"];
if refs.is_null() {
// Repo may be deleted or inaccessible — treat as empty.
return Ok(Vec::new());
}
if let Some(nodes) = refs["nodes"].as_array() {
for node in nodes {
let branch = node["name"].as_str();
let head_login = node["target"]["author"]["user"]["login"].as_str();
if let (Some(b), Some(login)) = (branch, head_login) {
if login.eq_ignore_ascii_case(user_login) {
branches.push(b.to_string());
}
}
}
}
let has_next = refs["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false);
if !has_next {
break;
}
cursor = refs["pageInfo"]["endCursor"].as_str().map(String::from);
}
Ok(branches)
}
/// List every branch in a repo. Returns an empty vec for empty (409)
/// or missing (404) repos; surfaces rate-limit / transport errors so the
/// caller can decide whether to bail.
async fn list_branches(&self, repo: &Repo) -> Result<Vec<String>, SourceError> {
let mut branches = Vec::new();
for page in 1..=10u32 {
let url = format!(
"https://api.github.com/repos/{}/branches?per_page={}&page={}",
repo.full_name, self.config.per_page, page
);
let req = self.apply_headers(self.client.get(&url));
let resp = req
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 404 || status.as_u16() == 409 {
return Ok(Vec::new());
}
if status.as_u16() == 403 || status.as_u16() == 429 {
return Err(SourceError::Http(format!("{} GET {}", status, url)));
}
if !status.is_success() {
return Err(SourceError::Http(format!("{} GET {}", status, url)));
}
let items: Vec<Value> = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
if items.is_empty() {
break;
}
for item in &items {
if let Some(name) = item.get("name").and_then(Value::as_str) {
branches.push(name.to_string());
}
}
if items.len() < self.config.per_page as usize {
break;
}
}
Ok(branches)
}
/// Fetch commits for a single repo across all branches the user has
/// touched. Per-branch state keys (`github-repo:{full_name}@{branch}`)
/// hold the newest seen commit timestamp so each branch can be
/// incremented independently — important because a brand new branch's
/// `since` cursor must start unset even when the default branch has
/// been polled many times already.
///
/// When `user_id` is supplied, branches are pre-filtered via GraphQL
/// to those with at least one commit by the user — vastly cheaper for
/// large upstream forks where most branches were never touched. On
/// GraphQL failure (or no token), falls back to the REST branch list
/// and relies on the per-branch 500-as-empty handling to discard the
/// noise.
async fn scan_repo(&self, repo: &Repo) -> Result<usize, SourceError> {
let state_key = format!("github-repo:{}", repo.full_name);
let branches = if self.config.token.is_some() {
match self.list_branches_with_commits(repo, &self.config.user).await {
Ok(b) => b,
Err(e) => {
warn!(repo = %repo.full_name, error = %e, "graphql branch filter failed; falling back to REST");
self.list_branches(repo).await?
}
}
} else {
self.list_branches(repo).await?
};
if branches.is_empty() {
return Ok(0);
}
let mut total = 0usize;
// Dedup commits seen via multiple branches in one tick. Without this
// the same SHA appears in the upsert batch twice (postgres rejects
// duplicate conflict targets in a single INSERT).
let mut seen_in_tick: HashSet<String> = HashSet::new();
for branch in &branches {
match self.scan_repo_branch(repo, branch, &mut seen_in_tick).await {
Ok(n) => total += n,
Err(SourceError::Http(ref msg)) if msg.starts_with("403") || msg.starts_with("429") => {
return Err(SourceError::Http(msg.clone()));
}
Err(e) => {
warn!(repo = %repo.full_name, branch = %branch, error = %e, "branch scan failed; continuing");
}
}
}
Ok(total)
}
async fn scan_repo_branch(
&self,
repo: &Repo,
branch: &str,
seen_in_tick: &mut HashSet<String>,
) -> Result<usize, SourceError> {
let state_key = format!("github-repo:{}@{}", repo.full_name, branch);
let prior = self.state.load(&state_key).await?;
let since = prior.as_ref().and_then(|s| s.last_modified);
let encoded_branch = utf8_percent_encode(branch, BRANCH_ENCODE_SET).to_string();
let mut total = 0usize;
let mut newest: Option<DateTime<Utc>> = since;
for page in 1..=MAX_BACKFILL_PAGES {
let mut url = format!(
"https://api.github.com/repos/{}/commits?author={}&per_page={}&page={}",
repo.full_name, self.config.user, self.config.per_page, page
"https://api.github.com/repos/{}/commits?author={}&sha={}&per_page={}&page={}",
repo.full_name, self.config.user, encoded_branch, self.config.per_page, page
);
if let Some(since_dt) = since {
url.push_str(&format!("&since={}", since_dt.to_rfc3339()));
@@ -256,11 +479,20 @@ impl GithubRepoSource {
break;
}
if status.as_u16() == 403 || status.as_u16() == 429 {
warn!(repo = %repo.full_name, status = %status, "rate limited; stopping early");
warn!(repo = %repo.full_name, branch = %branch, status = %status, "rate limited; stopping early");
return Err(SourceError::Http(format!("{} GET {}", status, url)));
}
if status.as_u16() == 404 {
warn!(repo = %repo.full_name, "repo not found; skipping");
warn!(repo = %repo.full_name, branch = %branch, "repo or branch not found; skipping");
break;
}
// GitHub's `/repos/.../commits?author=X&sha=branch` returns 500
// (not an empty array) when the user has zero commits on the
// specified branch. Treat it as "no commits on this branch"
// rather than a server error — surfacing it as a warning floods
// logs on forks whose branches were all authored by upstream.
if status.as_u16() == 500 {
debug!(repo = %repo.full_name, branch = %branch, "no commits by author on branch (500)");
break;
}
if !status.is_success() {
@@ -275,16 +507,32 @@ impl GithubRepoSource {
break;
}
let events: Vec<Event> = items
.iter()
.filter_map(|item| parse_commit(item, repo))
.collect();
for ev in &events {
newest = Some(match newest {
Some(n) if ev.occurred_at > n => ev.occurred_at,
Some(n) => n,
None => ev.occurred_at,
});
let mut events = Vec::with_capacity(items.len());
for item in &items {
if let Some(ev) = parse_commit(item, repo) {
if seen_in_tick.insert(ev.id.clone()) {
if let Some(n) = newest {
if ev.occurred_at > n {
newest = Some(ev.occurred_at);
}
} else {
newest = Some(ev.occurred_at);
}
events.push(ev);
} else {
// Already ingested via another branch this tick;
// still advance `newest` so the per-branch cursor
// doesn't get stuck behind shared history.
let occurred = parse_commit_date(item);
if let Some(t) = occurred {
newest = Some(match newest {
Some(n) if t > n => t,
Some(n) => n,
None => t,
});
}
}
}
}
total += self.writer.upsert_events(&events).await?;
@@ -451,8 +699,7 @@ fn parse_repo(item: &Value) -> Option<Repo> {
})
}
fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
let sha = item.get("sha").and_then(Value::as_str)?;
fn parse_commit_date(item: &Value) -> Option<DateTime<Utc>> {
let date_str = item
.get("commit")
.and_then(|c| c.get("author"))
@@ -464,9 +711,16 @@ fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
.and_then(|c| c.get("date"))
.and_then(Value::as_str)
})?;
let occurred_at = DateTime::parse_from_rfc3339(date_str)
.ok()?
.with_timezone(&Utc);
Some(
DateTime::parse_from_rfc3339(date_str)
.ok()?
.with_timezone(&Utc),
)
}
fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
let sha = item.get("sha").and_then(Value::as_str)?;
let occurred_at = parse_commit_date(item)?;
let mut payload = item.clone();
if let Some(obj) = payload.as_object_mut() {

View File

@@ -113,8 +113,11 @@ impl GithubSearchSource {
) -> Result<usize, SourceError> {
let mut total = 0usize;
for page in 1..=self.config.max_pages {
// `fork:true` opts forks into the search — by default GitHub's
// search API excludes them entirely, which means commits on a
// user's fork (regardless of branch) never surface here.
let url = format!(
"https://api.github.com/search/commits?q=author:{}&sort=author-date&order=desc&per_page={}&page={}",
"https://api.github.com/search/commits?q=author:{}+fork:true&sort=author-date&order=desc&per_page={}&page={}",
self.config.user, self.config.per_page, page
);
let req = self.apply_headers(self.client.get(&url));

View File

@@ -60,10 +60,24 @@ while [[ $# -gt 0 ]]; do
done
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
command -v yq >/dev/null 2>&1 || die "yq is required"
command -v pass >/dev/null 2>&1 || die "pass is required"
command -v rsync >/dev/null 2>&1 || die "rsync is required"
command -v cargo >/dev/null 2>&1 || die "cargo is required"
command -v yq >/dev/null 2>&1 || die "yq is required"
command -v pass >/dev/null 2>&1 || die "pass is required"
command -v rsync >/dev/null 2>&1 || die "rsync is required"
command -v cargo >/dev/null 2>&1 || die "cargo is required"
command -v podman >/dev/null 2>&1 || die "podman is required (used for the deploy build container)"
# Rust binaries are built inside a Debian container so the resulting ELF
# links against an older glibc than this workstation's. Building natively
# on f44 (glibc 2.43) produces binaries that won't load on f42 / f43
# servers — the dynamic loader refuses them outright. Debian bookworm's
# glibc 2.36 is older than every Fedora release we deploy to, so its
# binaries are forward-compatible.
#
# The artifacts land in target/deploy/release/ so a native `cargo build`
# in this checkout (for tests, clippy, dev runs) doesn't compete with
# the container for incremental state, and vice-versa.
rust_build_image="docker.io/library/rust:1-bookworm"
rust_target_dir="${repo_root}/target/deploy"
# Resolve component list ----------------------------------------------------
@@ -93,8 +107,20 @@ for c in "${components[@]}"; do
done
if (( needs_rust )); then
log "cargo build --release (api, worker)"
run cargo build --release --bin moments-api --bin moments-worker --manifest-path "${repo_root}/Cargo.toml"
log "cargo build --release in ${rust_build_image} (api, worker)"
install --directory "$rust_target_dir"
# Named volumes cache the cargo registry and git index across runs so
# subsequent builds don't re-fetch every crate. CARGO_TARGET_DIR
# redirects build output into the host-mounted target/deploy.
# :Z relabels the bind mount for SELinux on Fedora hosts.
run podman run --rm \
--volume "${repo_root}:/workspace:Z" \
--volume moments-deploy-cargo-registry:/usr/local/cargo/registry \
--volume moments-deploy-cargo-git:/usr/local/cargo/git \
--workdir /workspace \
--env CARGO_TARGET_DIR=/workspace/target/deploy \
"$rust_build_image" \
cargo build --release --bin moments-api --bin moments-worker
fi
if (( needs_web )); then
@@ -156,7 +182,7 @@ deploy_api() {
install --mode=0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
install --mode=0755 "${rust_target_dir}/release/moments-api" "$stage/usr/local/bin/moments-api"
chmod 0640 "$stage/etc/moments/api.env"
@@ -310,7 +336,7 @@ deploy_worker() {
install --mode=0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
install --mode=0755 "${rust_target_dir}/release/moments-worker" "$stage/usr/local/bin/moments-worker"
chmod 0640 "$stage/etc/moments/worker.env"

View File

@@ -19,7 +19,10 @@
"react-dom": "^19.0.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^7.14.2",
"react-vertical-timeline-component": "^3.6.0"
"react-vertical-timeline-component": "^3.6.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^19.0.0",

328
ui/pnpm-lock.yaml generated
View File

@@ -38,6 +38,15 @@ importers:
react-vertical-timeline-component:
specifier: ^3.6.0
version: 3.6.0(react@19.2.5)
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
devDependencies:
'@types/react':
specifier: ^19.0.0
@@ -625,11 +634,19 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
@@ -650,15 +667,36 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-sanitize@5.0.2:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -691,9 +729,33 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
mdast-util-from-markdown@2.0.3:
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
mdast-util-gfm-strikethrough@2.0.0:
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
mdast-util-gfm-table@2.0.0:
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
mdast-util-gfm-task-list-item@2.0.0:
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -718,6 +780,27 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
micromark-extension-gfm-footnote@2.1.0:
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
micromark-extension-gfm-strikethrough@2.1.0:
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
micromark-extension-gfm-table@2.1.1:
resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
micromark-extension-gfm-tagfilter@2.0.0:
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
micromark-extension-gfm-task-list-item@2.1.0:
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -793,6 +876,9 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -909,12 +995,24 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
rollup@4.60.2:
resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -993,6 +1091,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -1042,6 +1143,9 @@ packages:
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -1428,6 +1532,8 @@ snapshots:
'@babel/runtime': 7.29.2
csstype: 3.2.3
entities@6.0.1: {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
@@ -1457,6 +1563,8 @@ snapshots:
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
escape-string-regexp@5.0.0: {}
estree-util-is-identifier-name@3.0.0: {}
extend@3.0.2: {}
@@ -1468,6 +1576,43 @@ snapshots:
fsevents@2.3.3:
optional: true
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.1.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.1
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
parse5: 7.3.0
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-sanitize@5.0.2:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
unist-util-position: 5.0.0
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -1488,12 +1633,32 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
inline-style-parser@0.2.7: {}
invariant@2.2.4:
@@ -1521,6 +1686,15 @@ snapshots:
dependencies:
js-tokens: 4.0.0
markdown-table@3.0.4: {}
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.0.3:
dependencies:
'@types/mdast': 4.0.4
@@ -1538,6 +1712,63 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
ccount: 2.0.1
devlop: 1.1.0
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
mdast-util-gfm-footnote@2.1.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0:
dependencies:
mdast-util-from-markdown: 2.0.3
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.0
mdast-util-gfm-table: 2.0.0
mdast-util-gfm-task-list-item: 2.0.0
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -1629,6 +1860,64 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-footnote@2.1.0:
dependencies:
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-strikethrough@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-table@2.1.1:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-tagfilter@2.0.0:
dependencies:
micromark-util-types: 2.0.2
micromark-extension-gfm-task-list-item@2.1.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm@3.0.0:
dependencies:
micromark-extension-gfm-autolink-literal: 2.1.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-gfm-strikethrough: 2.1.0
micromark-extension-gfm-table: 2.1.1
micromark-extension-gfm-tagfilter: 2.0.0
micromark-extension-gfm-task-list-item: 2.1.0
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -1759,6 +2048,10 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse5@7.3.0:
dependencies:
entities: 6.0.1
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -1913,6 +2206,28 @@ snapshots:
react@19.2.5: {}
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
rehype-sanitize@6.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -1930,6 +2245,12 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
rollup@4.60.2:
dependencies:
'@types/estree': 1.0.8
@@ -2044,6 +2365,11 @@ snapshots:
dependencies:
react: 19.2.5
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -2069,4 +2395,6 @@ snapshots:
dependencies:
loose-envify: 1.4.0
web-namespaces@2.0.1: {}
zwitch@2.0.4: {}

View File

@@ -4,6 +4,9 @@ import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
@@ -82,7 +85,12 @@ export function ProjectPage() {
<Row className="mb-4">
<Col>
<div className="project-readme">
<ReactMarkdown>{readmeQ.data}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, readmeSanitizeSchema]]}
>
{readmeQ.data}
</ReactMarkdown>
</div>
</Col>
</Row>
@@ -126,3 +134,49 @@ function forgeIcon(source: string): string {
}
}
// rehype-sanitize defaults are conservative — README authors lean on raw
// HTML for layout (centered headers, collapsible sections, image
// dimensions). Extend the schema to permit those tags/attributes while
// still blocking script-y or interactive content (iframe, object, etc.).
const readmeSanitizeSchema = {
...defaultSchema,
tagNames: [
...(defaultSchema.tagNames ?? []),
'details',
'summary',
'picture',
'source',
'kbd',
'sub',
'sup',
'mark',
'abbr',
'cite',
'figure',
'figcaption',
'center',
],
attributes: {
...defaultSchema.attributes,
'*': [
...((defaultSchema.attributes && defaultSchema.attributes['*']) || []),
'align',
'style',
],
a: [
...((defaultSchema.attributes && defaultSchema.attributes.a) || []),
'target',
'rel',
],
img: [
...((defaultSchema.attributes && defaultSchema.attributes.img) || []),
'width',
'height',
'align',
'srcset',
],
source: ['srcset', 'media', 'type'],
details: ['open'],
},
};