Skip to content

feat: resolve team avatars to GitHub profiles (#78)#101

Open
AaronFeledy wants to merge 11 commits into
mainfrom
issue-78-github-team-links
Open

feat: resolve team avatars to GitHub profiles (#78)#101
AaronFeledy wants to merge 11 commits into
mainfrom
issue-78-github-team-links

Conversation

@AaronFeledy
Copy link
Copy Markdown
Member

@AaronFeledy AaronFeledy commented Apr 29, 2026

Closes #78. Team-card avatars (and blog post bylines) now link to GitHub profiles and use GitHub profile pictures when a contributor's commit email matches a GitHub user.

Resolution uses cached GraphQL calls to GitHub's repository.history. First build walks history once and writes docs/.vitepress/cache/team-github.json; subsequent builds make zero API calls.

When GitHub resolution is on (default), unresolved contributors get no avatar link rather than a mailto: — opting into GitHub avatars opts you out of email exposure on public pages. Configurable.

New themeConfig.contributors options

Option Default Notes
resolveGitHub 'auto' 'auto' tries when GITHUB_TOKEN is set, else skips
cachePath 'docs/.vitepress/cache/team-github.json' Relative to git root
repo sniffed from git remote 'owner/name' override
mailtoFallback 'auto' 'auto' = off when resolveGitHub is on
maxPages 100 Commit-history pages per run
maxStalePages 10 Bail after N pages with no new resolutions

Verified

this repo lando/core
Contributors 5 235
GitHub-linked 4 204
Unlinked (gravatar only) 1 31
mailto: on team.html 0 0

Backward-compatible: set resolveGitHub: false (or mailtoFallback: true) for legacy behavior.


Note

Medium Risk
Adds build-time GitHub GraphQL lookups and on-disk caching to enrich contributor identity, which can affect builds and external API usage if misconfigured (token/repo/caching). UI link behavior also changes from mailto: to GitHub/none depending on config.

Overview
Upgrades contributor identity to GitHub-backed profiles. Team member avatars and blog bylines now prefer linking to GitHub profiles (and show @username in tooltips) via a shared getAuthorLink helper, with mailto: only used when themeConfig.contributors.mailtoFallback is enabled.

Adds optional GitHub username resolution with caching and better performance. getContributors can now resolve commit emails to GitHub logins via a paginated GraphQL history walk, persist results to themeConfig.contributors.cachePath, and reuse a build-wide ctx so resolution (and git remote repo sniffing) runs once per build instead of per page/content-loader item; explicit resolveGitHub: true now warns when no GITHUB_TOKEN/GH_TOKEN is set.

Config/docs updates. Default/preset contributor configs gain resolveGitHub, cachePath, and mailtoFallback (with 'auto' behavior), and documentation/changelog are updated to describe the new options and behavior.

Reviewed by Cursor Bugbot for commit 5b6a0ed. Bugbot is set up for automated code reviews on this repo. Configure here.

Resolves #78. Team-card avatars now link to a contributor's GitHub
profile (and use their GitHub profile picture) when their commit email
can be matched to a GitHub user. The mailto: link remains as the
last-resort fallback for contributors whose email isn't tied to any
GitHub account.

Resolution happens via a single GraphQL query to GitHub's
repository.history endpoint. Results are cached to disk (positives and
negatives both) so subsequent builds make zero API calls. The walker
bails after maxStalePages (5) consecutive pages with no progress, so
unresolvable emails don't waste calls on every fresh build.

New config options under themeConfig.contributors:
  - resolveGitHub: 'auto' | true | false (default 'auto')
  - cachePath: e.g. 'docs/.vitepress/cache/team-github.json'
  - repo: optional 'owner/name' override (auto-sniffed from git remote)

Configured maintainers with an existing github link in their `links`
array also benefit: we scrape the username from the link itself
(no API call) to populate the new `member.github` field, swap their
gravatar avatar for a GitHub avatar, and improve the tooltip.

The avatar tooltip now prefers @username over <email> when available,
reducing email-address exposure on public team pages.
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 29, 2026

Deploy Preview for vitepress-theme-default-plus ready!

Name Link
🔨 Latest commit 5b6a0ed
🔍 Latest deploy log https://app.netlify.com/projects/vitepress-theme-default-plus/deploys/69f252bfa744250008224dc8
😎 Deploy Preview https://deploy-preview-101--vitepress-theme-default-plus.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 80 (🟢 up 27 from production)
Accessibility: 100 (no change from production)
Best Practices: 92 (no change from production)
SEO: 92 (🟢 up 7 from production)
PWA: -
View the detailed breakdown and full score reports
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: No behavioral difference between 'auto' and true modes
    • Added early short-circuit for 'auto' mode when no token is present, and console warning for 'true' mode to distinguish behaviors.

Create PR

Or push these changes by commenting:

@cursor push 0e7ef0ad21
Preview (0e7ef0ad21)
diff --git a/node/resolve-github-usernames.js b/node/resolve-github-usernames.js
--- a/node/resolve-github-usernames.js
+++ b/node/resolve-github-usernames.js
@@ -97,6 +97,7 @@
   // 5 is forgiving enough that a contributor whose only commits are buried
   // a few pages deep (e.g. they switched emails) still gets resolved.
   maxStalePages = 5,
+  warnOnMissingToken = false,
   debug = Debug('@lando/resolve-github-usernames'), // eslint-disable-line
 } = {}) {
   // start with whatever's in the cache (a prior run's results). cache values
@@ -118,6 +119,9 @@
 
   // need a token to talk to the API; warn and bail if missing
   if (!token) {
+    if (warnOnMissingToken) {
+      console.warn('[vitepress-theme-default-plus] resolveGitHub is set to `true` but no GITHUB_TOKEN or GH_TOKEN environment variable is set; skipping GitHub username resolution');
+    }
     debug('no GITHUB_TOKEN/GH_TOKEN env var set; skipping API resolution for %o emails', unresolved.size);
     return result;
   }

diff --git a/utils/get-contributors.js b/utils/get-contributors.js
--- a/utils/get-contributors.js
+++ b/utils/get-contributors.js
@@ -184,42 +184,49 @@
   // a `github` field for tooltips, and seeds a GitHub link in the
   // contributor's social-links array when none is configured.
   if (resolveGitHub !== false && data.length > 0) {
-    let mappings = null;
-    const repoCoord = repo && typeof repo === 'object' && repo.owner ? repo : getRepoCoordinate(cwd, {
-      override: typeof repo === 'string' ? repo : undefined,
-      packageJson,
-      debug: debug.extend('repo-coord'),
-    });
+    // 'auto' mode: short-circuit early if no token is present, avoiding
+    // unnecessary repo-coordinate lookup and resolver invocation
+    if (resolveGitHub === 'auto' && !token && !process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
+      debug('resolveGitHub is "auto" and no token available; skipping GitHub username resolution');
+    } else {
+      let mappings = null;
+      const repoCoord = repo && typeof repo === 'object' && repo.owner ? repo : getRepoCoordinate(cwd, {
+        override: typeof repo === 'string' ? repo : undefined,
+        packageJson,
+        debug: debug.extend('repo-coord'),
+      });
 
-    if (repoCoord) {
-      // only ask the api for emails we don't already have a github link for
-      // (configured maintainers contribute their username via the link)
-      const emailsToResolve = data
-        .filter(c => !c.links?.some(link => link?.icon === 'github'))
-        .map(c => c.email)
-        .filter(Boolean);
+      if (repoCoord) {
+        // only ask the api for emails we don't already have a github link for
+        // (configured maintainers contribute their username via the link)
+        const emailsToResolve = data
+          .filter(c => !c.links?.some(link => link?.icon === 'github'))
+          .map(c => c.email)
+          .filter(Boolean);
 
-      if (emailsToResolve.length > 0) {
-        // resolve relative cache paths against the git root so users can
-        // configure something convenient like 'docs/.vitepress/cache/...'
-        const resolvedCachePath = cachePath
-          ? (isAbsolute(cachePath) ? cachePath : resolve(cwd, cachePath))
-          : undefined;
-        mappings = await resolveGitHubUsernames(emailsToResolve, {
-          repo: repoCoord,
-          token,
-          cachePath: resolvedCachePath,
-          debug: debug.extend('resolve-github'),
-        });
-      } else {
-        debug('all contributors already have github links configured; skipping API resolution');
+        if (emailsToResolve.length > 0) {
+          // resolve relative cache paths against the git root so users can
+          // configure something convenient like 'docs/.vitepress/cache/...'
+          const resolvedCachePath = cachePath
+            ? (isAbsolute(cachePath) ? cachePath : resolve(cwd, cachePath))
+            : undefined;
+          mappings = await resolveGitHubUsernames(emailsToResolve, {
+            repo: repoCoord,
+            token,
+            cachePath: resolvedCachePath,
+            debug: debug.extend('resolve-github'),
+            warnOnMissingToken: resolveGitHub === true,
+          });
+        } else {
+          debug('all contributors already have github links configured; skipping API resolution');
+        }
       }
+
+      // always apply — even with no api mappings, this scrapes existing
+      // github links on maintainer entries and uses them to swap avatars
+      // and populate the `github` field
+      applyGitHubLogins(data, mappings);
     }
-
-    // always apply — even with no api mappings, this scrapes existing
-    // github links on maintainer entries and uses them to swap avatars
-    // and populate the `github` field
-    applyGitHubLogins(data, mappings);
   }
 
   // separate maintainers from contribs

You can send follow-ups to the cloud agent here.

Comment thread utils/get-contributors.js
…lers

Verified the original commit against the lando/core docs site (which
uses this theme) and surfaced three issues that left most contributors
falling back to mailto:

1. cachePath wasn't a default. The lando v3/v4 presets in
   config/landov{3,4}.js define their own contributors block that
   overrides the base default, so resolveGitHub:'auto' was kicking in
   but cachePath was undefined. Result: cache file never got written,
   so per-page transformPageData calls re-ran the resolver fresh
   instead of hitting cache.

2. maxPages=10 was too small. lando/core has 223 unique contributors
   across ~7000 commits; 1000 commits scanned only resolved ~14 of
   them. Bumped maxPages default to 100 (10000 commits) and
   maxStalePages to 10. With 100 pages of headroom the walker either
   exhausts history (typical case) or bails on a long stretch of
   unresolvable emails.

3. Hitting maxPages was poisoning the negative cache. When the walker
   cut off early at maxPages, unresolved emails got null'd in the
   cache and never retried. Fixed: only write null when the search
   actually exhausted (ran off the end of history or hit
   maxStalePages). Cut-offs at maxPages leave emails unrecorded so
   the next build can pick them up.

Also threaded maxPages and maxStalePages as configurable options on
themeConfig.contributors for users with deeper-than-default histories.

After these fixes, lando/core resolves 190/223 contributors (up from
17/223). Remaining 31 are genuinely unresolvable (deleted accounts,
typos, unconnected emails) and stay as mailto: fallbacks.
When GitHub resolution is enabled (the default), an unresolvable
contributor's avatar no longer falls back to a `mailto:` link. The
avatar simply isn't a link at all — the existing VPLLink component
already handles a missing href by rendering a <span> instead of an
<a>, so there's no broken-link rendering.

This is a deliberate privacy improvement: once a docs site is using
GitHub resolution, the only contributors whose avatars still leak
their commit email were the ones we *couldn't* match to a GitHub
account anyway, so the mailto: was already low-value. Suppressing it
prevents accidental email-harvesting from public team pages.

Verified on lando/core: 235 contributors total, 204 now link to
GitHub, 31 remain as unlinked gravatar placeholders, ZERO mailto:
links anywhere on team.html (down from 31 mailtos with the old
fallback behavior).

Configurable via themeConfig.contributors.mailtoFallback:
  - 'auto' (default): off when resolveGitHub is on, on when it's off
  - true: always keep the legacy mailto fallback
  - false: never use mailto fallback

The 'auto' value is resolved to a boolean once in defineConfig and
threaded through to both the team template (VPLTeamMembersItem.vue,
read via useData()) and the blog byline pipeline (augment-authors.js,
passed in from the transformPageData callback).

The avatar tooltip is gated the same way: when mailto fallback is
off, an unresolved contributor's tooltip shows just their name +
commit count, never their email address.
@AaronFeledy AaronFeledy changed the title feat: link team-card avatars to GitHub profiles instead of mailto (#78) feat: resolve team avatars to GitHub profiles (#78) Apr 29, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Per-page subprocess spawn for repo coordinate detection
    • Cached repo coordinate and GitHub username mappings once during config initialization to eliminate redundant per-page subprocess spawns and synchronous file I/O operations.

Create PR

Or push these changes by commenting:

@cursor push 1f470766e3
Preview (1f470766e3)
diff --git a/config.js b/config.js
--- a/config.js
+++ b/config.js
@@ -1,6 +1,6 @@
 // mods
-import {existsSync} from 'node:fs';
-import {dirname, resolve} from 'node:path';
+import {existsSync, readFileSync} from 'node:fs';
+import {dirname, isAbsolute, resolve} from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import isEmpty from 'lodash-es/isEmpty.js';
@@ -15,6 +15,7 @@
 import {default as getContributors} from './utils/get-contributors.js';
 import {default as getGaHeaders} from './utils/get-ga-headers.js';
 import {default as getHubspotHeaders} from './utils/get-hubspot-headers.js';
+import {default as getRepoCoordinate} from './utils/get-repo-coordinate.js';
 import {default as getTags} from './utils/get-tags.js';
 import {default as normalizeMVB} from './utils/normalize-mvb.js';
 import {default as parseLayouts} from './utils/parse-layouts.js';
@@ -195,6 +196,38 @@
     debug('added hubspot tracking with %o', hubspot);
   }
 
+  // compute repo coordinate and read GitHub username cache once for the entire
+  // build to avoid repeated subprocess spawns and synchronous file I/O in
+  // per-page getContributors calls
+  if (contributors !== false && typeof contributors === 'object') {
+    if (!contributors.repo || typeof contributors.repo === 'string') {
+      const repoCoord = getRepoCoordinate(config.gitRoot, {
+        override: typeof contributors.repo === 'string' ? contributors.repo : undefined,
+        debug: debug.extend('repo-coord'),
+      });
+      if (repoCoord) {
+        contributors.repo = repoCoord;
+        debug('cached repo coordinate %o/%o for per-page contributor resolution', repoCoord.owner, repoCoord.name);
+      }
+    }
+
+    // read the GitHub username mappings cache once if configured
+    if (contributors.cachePath && !contributors.cachedMappings) {
+      const resolvedCachePath = isAbsolute(contributors.cachePath)
+        ? contributors.cachePath
+        : resolve(config.gitRoot, contributors.cachePath);
+      if (existsSync(resolvedCachePath)) {
+        try {
+          contributors.cachedMappings = JSON.parse(readFileSync(resolvedCachePath, 'utf8'));
+          debug('cached %o email->login mappings from %o for per-page contributor resolution',
+            Object.keys(contributors.cachedMappings).length, resolvedCachePath);
+        } catch (error) {
+          debug('failed to read cache file %o: %o', resolvedCachePath, error.message);
+        }
+      }
+    }
+  }
+
   // get full team info
   const copts = {debug: debug.extend('get-contribs'), paths: []};
   const team = contributors !== false ? await getContributors(config.gitRoot, contributors, copts) : [];

diff --git a/node/resolve-github-usernames.js b/node/resolve-github-usernames.js
--- a/node/resolve-github-usernames.js
+++ b/node/resolve-github-usernames.js
@@ -90,6 +90,7 @@
   repo,
   token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
   cachePath,
+  cachedMappings,
   // hard ceiling on commit-history pages we'll fetch in a single run.
   // 100 pages = 10000 commits, which covers all but the largest repos.
   // bumping this is cheap rate-limit wise (1 graphql point per page) but
@@ -107,7 +108,7 @@
   // start with whatever's in the cache (a prior run's results). cache values
   // can be a string (resolved login) or null (we tried and couldn't resolve;
   // skip on subsequent runs to avoid burning api calls every build)
-  const cache = readCache(cachePath, debug);
+  const cache = cachedMappings || readCache(cachePath, debug);
   const result = new Map(Object.entries(cache));
 
   // figure out which emails still need resolving — anything not in the

diff --git a/utils/get-contributors.js b/utils/get-contributors.js
--- a/utils/get-contributors.js
+++ b/utils/get-contributors.js
@@ -69,6 +69,7 @@
     exclude = [],
     resolveGitHub = 'auto',
     cachePath,
+    cachedMappings,
     repo,
     token,
     maxPages,
@@ -211,6 +212,7 @@
           repo: repoCoord,
           token,
           cachePath: resolvedCachePath,
+          cachedMappings,
           maxPages,
           maxStalePages,
           debug: debug.extend('resolve-github'),

You can send follow-ups to the cloud agent here.

Comment thread utils/get-contributors.js Outdated
Previously, getContributors ran the GitHub-resolution flow inside every
transformPageData call: each page spawned 'git remote get-url origin' to
detect the repo coordinate and re-read the on-disk team-github.json
cache. On a docs site like lando/core (~hundreds of pages and ~235
contributors) that meant hundreds of subprocess spawns and synchronous
file reads per build, all returning identical results.

Thread a shared 'ctx' object through the contributor pipeline:

- utils/get-contributors.js: accept 'ctx' in opts. First call populates
  ctx.repoCoord and ctx.mappings; subsequent calls reuse them and skip
  the resolution work entirely. Detect token presence here so the new
  'true' vs 'auto' distinction can warn loudly when the user explicitly
  set resolveGitHub: true but no GITHUB_TOKEN/GH_TOKEN is available.

- node/add-contributors.js: forward 'ctx' to getContributors.

- config.js: own a single contributorCtx that's used by the build-time
  team lookup AND every per-page transformPageData call.

- utils/create-content-loader.js: use a content-loader-scoped ctx so the
  RSS feed path (createContentLoader → addContributors per blog post)
  also resolves once instead of once per post.

After this change on this repo's own docs (44 pages, 2 blog posts):
  - 'parsed repo coordinate' calls: 35 → 2
  - 'loaded N cached email->login mappings' (disk reads): 44 → 2
  - 'reusing pre-resolved GitHub mappings' (ctx hits): 0 → 77

The two remaining resolutions are one per resolution context (page
render + RSS feed gen), which is the natural floor.

Also fixes the documented but previously-identical 'auto' and true
modes: when resolveGitHub: true and no token is present, we now warn
that resolution will fall back to cached results only — surfacing
misconfiguration instead of silently degrading.

Addresses Cursor Bugbot reviews on PR #101.
@AaronFeledy AaronFeledy force-pushed the issue-78-github-team-links branch from d49e215 to 9899fba Compare April 29, 2026 05:04
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Content loader omits mailtoFallback for blog authors
    • Added mailtoFallback parameter to augmentAuthors call in create-content-loader.js matching the pattern used in config.js.

Create PR

Or push these changes by commenting:

@cursor push c49c1d0f0a
Preview (c49c1d0f0a)
diff --git a/utils/create-content-loader.js b/utils/create-content-loader.js
--- a/utils/create-content-loader.js
+++ b/utils/create-content-loader.js
@@ -65,7 +65,7 @@
         // parse collections
         await parseCollections(data, {siteConfig, debug});
         // normalize authors
-        await augmentAuthors(data, {team, debug});
+        await augmentAuthors(data, {team, mailtoFallback: contributors?.mailtoFallback === true, debug});
 
         // get stuff
         const {frontmatter, html, url} = data;

You can send follow-ups to the cloud agent here.

Comment thread utils/create-content-loader.js
createContentLoader.transform was calling augmentAuthors() without
passing mailtoFallback, so it silently defaulted to false. The parallel
call from config.js's transformPageData passes
'mailtoFallback: contributors?.mailtoFallback === true', so team-page
bylines and blog-post bylines diverged: a user with mailtoFallback: true
(or resolveGitHub: false, which auto-resolves mailtoFallback to true)
got mailto: links on team pages but not on blog posts.

Mirror the same one-liner from config.js so the two paths agree.

Spotted by Cursor Bugbot on PR #101.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicated link resolution logic risks future divergence
    • Extracted duplicated link resolution logic into a shared utility function (utils/get-contributor-link.js) that both augment-authors.js and VPLTeamMembersItem.vue now use, ensuring consistent link resolution across blog bylines and team cards.

Create PR

Or push these changes by commenting:

@cursor push 310da5b012
Preview (310da5b012)
diff --git a/components/VPLTeamMembersItem.vue b/components/VPLTeamMembersItem.vue
--- a/components/VPLTeamMembersItem.vue
+++ b/components/VPLTeamMembersItem.vue
@@ -96,6 +96,7 @@
 import VPIconHeart from 'vitepress/dist/client/theme-default/components/icons/VPIconHeart.vue';
 import VPSocialLinks from 'vitepress/dist/client/theme-default/components/VPSocialLinks.vue';
 import Link from './VPLLink.vue';
+import getContributorLink from '../utils/get-contributor-link.js';
 
 const {member, size} = defineProps({
   size: {
@@ -133,13 +134,7 @@
 
 const maintainerClass = computed(() => member.maintainer ? 'maintainer' : '');
 
-const getLink = member => {
-  if (member.link) return member.link;
-  else if (Array.isArray(member?.links) && member.links[0]) return member.links[0].link;
-  else if (member.github) return `https://github.com/${member.github}`;
-  else if (member.email && mailtoFallback.value) return `mailto:${member.email}`;
-  return undefined;
-};
+const getLink = member => getContributorLink(member, mailtoFallback.value);
 
 const getAvatarTitle = member => {
   let avatarTitle = `${member.name}`;

diff --git a/node/augment-authors.js b/node/augment-authors.js
--- a/node/augment-authors.js
+++ b/node/augment-authors.js
@@ -1,16 +1,9 @@
 import Debug from 'debug';
+import getContributorLink from '../utils/get-contributor-link.js';
 
 const getContributor = (id, contributors = []) => contributors.find(contributor => contributor.email === id)
   ?? contributors.find(contributor => contributor.name === id);
 
-const getLink = (author, mailtoFallback) => {
-  if (author.link) return author.link;
-  else if (Array.isArray(author?.links) && author.links[0]) return author.links[0].link;
-  else if (author.github) return `https://github.com/${author.github}`;
-  else if (author.email && mailtoFallback) return `mailto:${author.email}`;
-  return undefined;
-};
-
 export default async function(pageData, {
   team,
   // when false (the default with github resolution enabled), unresolved
@@ -28,7 +21,7 @@
     frontmatter.authors = frontmatter.authors
       .map(author => typeof author === 'string' ? getContributor(author, team) : author)
       .filter(author => author && author !== false && author !== null)
-      .map(author => ({...author, link: getLink(author, mailtoFallback)}));
+      .map(author => ({...author, link: getContributorLink(author, mailtoFallback)}));
   }
 
   // log

diff --git a/utils/get-contributor-link.js b/utils/get-contributor-link.js
new file mode 100644
--- /dev/null
+++ b/utils/get-contributor-link.js
@@ -1,0 +1,20 @@
+/**
+ * Resolves the primary link URL for a contributor/team member.
+ * Priority order:
+ * 1. Explicit `link` property
+ * 2. First entry in `links` array
+ * 3. GitHub profile URL from `github` field
+ * 4. mailto: fallback if enabled
+ * 5. undefined
+ *
+ * @param {Object} contributor - The contributor/member object
+ * @param {boolean} mailtoFallback - Whether to fallback to mailto: links
+ * @return {string|undefined} The resolved link URL
+ */
+export default function getContributorLink(contributor, mailtoFallback = false) {
+  if (contributor.link) return contributor.link;
+  else if (Array.isArray(contributor?.links) && contributor.links[0]) return contributor.links[0].link;
+  else if (contributor.github) return `https://github.com/${contributor.github}`;
+  else if (contributor.email && mailtoFallback) return `mailto:${contributor.email}`;
+  return undefined;
+}

You can send follow-ups to the cloud agent here.

Comment thread node/augment-authors.js Outdated
The link-resolution priority for contributor entries (link → links[0] →
github → mailto → undefined) was duplicated in two places:
  - node/augment-authors.js   (server-side, blog post bylines)
  - components/VPLTeamMembersItem.vue (client-side, team cards)

The two implementations were verbatim identical, modulo parameter name
('author' vs 'member') and how mailtoFallback was unwrapped (boolean vs
Vue ref .value). We just hit a related drift bug in 9759de8 where the
two render paths diverged on a parallel concern (mailtoFallback was
threaded through one but not the other), so the maintenance risk here
is concrete, not theoretical.

Hoist the rules into utils/get-author-link.js as a pure function and
have both files import it. Pure JS works in both Node and browser
contexts; no behavior change.

Verified: built dist/team.html has 4 distinct github.com/<user> links
and 0 mailto: links, identical to before. Built blog byline still
resolves to https://github.com/pirog.

Spotted by Cursor Bugbot on PR #101.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unvalidated cache parse can crash build on corruption
    • Added validation after JSON.parse to ensure the result is a plain object before returning it, preventing TypeError when Object.entries is called on the cache.

Create PR

Or push these changes by commenting:

@cursor push 2f9848e5ab
Preview (2f9848e5ab)
diff --git a/node/resolve-github-usernames.js b/node/resolve-github-usernames.js
--- a/node/resolve-github-usernames.js
+++ b/node/resolve-github-usernames.js
@@ -29,6 +29,7 @@
   if (!cachePath || !existsSync(cachePath)) return {};
   try {
     const data = JSON.parse(readFileSync(cachePath, 'utf8'));
+    if (!data || typeof data !== 'object' || Array.isArray(data)) return {};
     debug('loaded %o cached email->login mappings from %o', Object.keys(data).length, cachePath);
     return data;
   } catch (error) {

You can send follow-ups to the cloud agent here.

Comment thread node/resolve-github-usernames.js
A cache file containing valid JSON that isn't a plain object (null,
array, primitive) would silently produce a Map keyed by array indices
or characters, defeating the cache and re-hitting the GitHub API on
every build. Validate the parsed value before trusting it.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Stale GitHub mappings when per-page contributors differ from global
    • Added logic to resolve and merge new emails not yet in ctx.mappings on subsequent per-page calls.
  • ✅ Fixed: Avatar URL format may not resolve correctly
    • Changed avatar URL from avatars.githubusercontent.com/{login} to the documented github.com/{login}.png format.

Create PR

Or push these changes by commenting:

@cursor push bf6dd58d88
Preview (bf6dd58d88)
diff --git a/utils/get-contributors.js b/utils/get-contributors.js
--- a/utils/get-contributors.js
+++ b/utils/get-contributors.js
@@ -39,7 +39,7 @@
     if (!login) continue;
     contributor.github = login;
     if (isGravatarAvatar(contributor.avatar)) {
-      contributor.avatar = `https://avatars.githubusercontent.com/${login}`;
+      contributor.avatar = `https://github.com/${login}.png`;
     }
     contributor.links = Array.isArray(contributor.links) ? contributor.links : [];
     if (!contributor.links.some(link => link?.icon === 'github')) {
@@ -222,6 +222,35 @@
       }
     } else {
       debug('reusing pre-resolved GitHub mappings (%o entries)', ctx.mappings?.size ?? 0);
+      // resolve any new emails not yet in mappings (e.g., from per-page includes)
+      if (ctx.repoCoord && ctx.mappings !== null) {
+        const newEmailsToResolve = data
+          .filter(c => !c.links?.some(link => link?.icon === 'github'))
+          .map(c => c.email)
+          .filter(Boolean)
+          .filter(email => !ctx.mappings.has(email));
+
+        if (newEmailsToResolve.length > 0) {
+          debug('found %o new emails to resolve', newEmailsToResolve.length);
+          const apiToken = token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
+          const resolvedCachePath = cachePath
+            ? (isAbsolute(cachePath) ? cachePath : resolve(cwd, cachePath))
+            : undefined;
+          const newMappings = await resolveGitHubUsernames(newEmailsToResolve, {
+            repo: ctx.repoCoord,
+            token: apiToken,
+            cachePath: resolvedCachePath,
+            maxPages,
+            maxStalePages,
+            debug: debug.extend('resolve-github'),
+          });
+          if (newMappings) {
+            for (const [email, login] of newMappings.entries()) {
+              ctx.mappings.set(email, login);
+            }
+          }
+        }
+      }
     }
 
     // applies mappings AND scrapes hand-configured github links

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 5b6a0ed. Configure here.

Comment thread utils/get-contributors.js

// applies mappings AND scrapes hand-configured github links
applyGitHubLogins(data, ctx.mappings);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale GitHub mappings when per-page contributors differ from global

Low Severity

The emailsToResolve list is built only from contributors present in the first getContributors call (global team, paths: []). Once ctx.mappings is set, subsequent per-page calls skip resolution entirely. If a per-page call discovers a contributor (via page-specific frontmatter.contributors.include) whose email was never in the global data set, that email will never appear in ctx.mappings and will silently miss GitHub resolution without any retry mechanism across calls within the same build.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5b6a0ed. Configure here.

Comment thread utils/get-contributors.js
if (!login) continue;
contributor.github = login;
if (isGravatarAvatar(contributor.avatar)) {
contributor.avatar = `https://avatars.githubusercontent.com/${login}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatar URL format may not resolve correctly

Medium Severity

The avatar URL https://avatars.githubusercontent.com/${login} uses the GitHub login directly in the path. GitHub's documented avatar URL format is https://github.com/${login}.png (by username) or https://avatars.githubusercontent.com/u/${numeric_id} (by user ID). The format used here may not consistently resolve to a valid image, potentially breaking avatar display for all GitHub-resolved contributors.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5b6a0ed. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Please make Team link to Github profiles not email address

1 participant