Compare commits
3 Commits
9a8c0955b5
...
acb061baca
| Author | SHA1 | Date | |
|---|---|---|---|
|
acb061baca
|
|||
|
8a7177a54a
|
|||
|
818a535903
|
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1307,6 +1307,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"moments-core",
|
||||
"moments-entities",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -17,3 +17,4 @@ tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
percent-encoding = "2"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
328
ui/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user