Skip to content

Make profile name hyperlinkable from the app ui #5

@AmedeoPelliccia

Description

@AmedeoPelliccia

Below is a clean, production-grade pattern that matches your app (HTML/SCSS/vanilla JS, animated UI, observer transitions).

  1. “Router” module: GitHub API endpoint caller + link builder

Create a small module that centralizes:
   •   GitHub API call (/users/{username})
   •   Optional call to repos (/users/{username}/repos)
   •   Canonical internal route (#/user/{username}) for in-app navigation
   •   External links (GitHub profile, blog, etc.)

js/github.router.js

// js/github.router.js
const GITHUB_API_BASE = "https://api.github.com";

function getAuthHeaders() {
// Optional: set window.GITHUB_TOKEN (or from build env) to avoid rate limits.
// Never hardcode tokens in public repos.
const token = window.GITHUB_TOKEN;
return token ? { Authorization: Bearer ${token} } : {};
}

export function buildInternalProfileRoute(username) {
// "Router name is hyperlinkable and functional"
// Example internal app route: #/user/octocat
return #/user/${encodeURIComponent(username)};
}

export function buildExternalProfileUrl(username) {
return https://github.com/${encodeURIComponent(username)};
}

export async function fetchGithubUser(username) {
const u = String(username || "").trim();
if (!/^[a-zA-Z0-9-]{1,39}$/.test(u)) {
throw new Error("Invalid GitHub username.");
}

const res = await fetch(${GITHUB_API_BASE}/users/${encodeURIComponent(u)}, {
headers: {
Accept: "application/vnd.github+json",
...getAuthHeaders(),
},
});

if (!res.ok) {
// 404 user not found, 403 rate limit, etc.
const text = await res.text().catch(() => "");
const err = new Error(GitHub API error: ${res.status});
err.status = res.status;
err.details = text.slice(0, 500);
throw err;
}

return res.json();
}

export async function fetchGithubRepos(username, { perPage = 12 } = {}) {
const u = String(username || "").trim();
if (!/^[a-zA-Z0-9-]{1,39}$/.test(u)) {
throw new Error("Invalid GitHub username.");
}

const url = new URL(${GITHUB_API_BASE}/users/${encodeURIComponent(u)}/repos);
url.searchParams.set("sort", "updated");
url.searchParams.set("per_page", String(perPage));

const res = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.github+json",
...getAuthHeaders(),
},
});

if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(GitHub API error: ${res.status});
err.status = res.status;
err.details = text.slice(0, 500);
throw err;
}

return res.json();
}

export function extractSocialLinks(user) {
// user.blog can be empty or missing scheme
const blog = (user.blog || "").trim();
const blogUrl =
blog && !/^https?:///i.test(blog) ? https://${blog} : blog;

return {
github: user.html_url || buildExternalProfileUrl(user.login),
blog: blogUrl || null,
// GitHub user object does not reliably include X/Twitter or LinkedIn.
// Twitter username is present as "twitter_username" for some accounts.
twitter: user.twitter_username
? https://x.com/${user.twitter_username}
: null,
};
}

This is your “router”: a single entry-point layer that routes all profile lookups through the GitHub API and guarantees consistent link output.

  1. Render: hyperlinkable name + external links (functional)

js/profile.view.js

// js/profile.view.js
import {
buildInternalProfileRoute,
fetchGithubUser,
fetchGithubRepos,
extractSocialLinks,
} from "./github.router.js";

export async function renderProfile(username, { mountEl }) {
mountEl.innerHTML = <div class="card loading">Loading…</div>;

try {
const [user, repos] = await Promise.all([
fetchGithubUser(username),
fetchGithubRepos(username, { perPage: 12 }),
]);

const internalRoute = buildInternalProfileRoute(user.login);
const socials = extractSocialLinks(user);

mountEl.innerHTML = `
  <section class="profile card observe-fade">
    <header class="profile__header">
      <img class="profile__avatar" src="${user.avatar_url}" alt="${user.login} avatar" />
      <div class="profile__meta">
        <a class="profile__name" href="${internalRoute}" aria-label="Open ${user.login} in app">
          ${escapeHtml(user.name || user.login)}
        </a>
        <div class="profile__handle">
          <a href="${user.html_url}" target="_blank" rel="noopener noreferrer">
            @${escapeHtml(user.login)}
          </a>
        </div>
        <p class="profile__bio">${escapeHtml(user.bio || "No bio available.")}</p>
      </div>
    </header>

    <div class="profile__stats">
      <div><strong>${user.public_repos}</strong><span>Repos</span></div>
      <div><strong>${user.followers}</strong><span>Followers</span></div>
      <div><strong>${user.following}</strong><span>Following</span></div>
    </div>

    <nav class="profile__links">
      ${socials.github ? linkChip("GitHub", socials.github) : ""}
      ${socials.blog ? linkChip("Website", socials.blog) : ""}
      ${socials.twitter ? linkChip("X", socials.twitter) : ""}
    </nav>

    <h3 class="profile__sectionTitle">Recently updated</h3>
    <ul class="repos">
      ${repos.map(repoItem).join("")}
    </ul>
  </section>
`;

// If you already have IntersectionObserver animations, observe these:
mountEl.querySelectorAll(".observe-fade").forEach((el) => {
  window.__observeFade?.(el); // optional hook
});

} catch (e) {
const msg =
e?.status === 404
? "User not found."
: e?.status === 403
? "Rate limited by GitHub API. Add a token or try again later."
: "Failed to load profile.";
mountEl.innerHTML = <div class="card error">${escapeHtml(msg)}</div>;
}
}

function repoItem(r) {
return <li class="repo observe-fade"> <a class="repo__name" href="${r.html_url}" target="_blank" rel="noopener noreferrer"> ${escapeHtml(r.name)} </a> <div class="repo__meta"> <span>${escapeHtml(r.language || "—")}</span> <span>★ ${r.stargazers_count}</span> <span>⑂ ${r.forks_count}</span> </div> ${r.description ?

${escapeHtml(r.description)}

: ""} </li>;
}

function linkChip(label, href) {
return <a class="chip" href="${href}" target="_blank" rel="noopener noreferrer"> ${escapeHtml(label)} </a>;
}

function escapeHtml(s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}

Key point: the profile name is hyperlinkable to an internal route (#/user/{username}), and you also create external links to GitHub and social URLs derived from the API response.

  1. Minimal hash-router to make the internal link truly functional

js/app.router.js

// js/app.router.js
import { renderProfile } from "./profile.view.js";

export function startAppRouter({ profileMountEl }) {
async function onRoute() {
const hash = window.location.hash || "#/";
const m = hash.match(/^#/user/([^/]+)$/);

if (m) {
  const username = decodeURIComponent(m[1]);
  return renderProfile(username, { mountEl: profileMountEl });
}

// Default route (search screen)
profileMountEl.innerHTML = `
  <div class="card">
    <p>Search for a GitHub user to view profile details.</p>
  </div>
`;

}

window.addEventListener("hashchange", onRoute);
onRoute();
}

Now the router name being hyperlinkable is not just decorative — it navigates.

  1. Wire it up to your live search

js/main.js

import { startAppRouter } from "./app.router.js";
import { buildInternalProfileRoute } from "./github.router.js";

const profileMountEl = document.querySelector("#profileMount");
const searchForm = document.querySelector("#searchForm");
const searchInput = document.querySelector("#searchInput");

startAppRouter({ profileMountEl });

searchForm.addEventListener("submit", (e) => {
e.preventDefault();
const username = searchInput.value.trim();
if (!username) return;
window.location.hash = buildInternalProfileRoute(username);
});

  1. Notes specific to “social links”

GitHub’s GET /users/{username} reliably gives:
   •   html_url (GitHub profile)
   •   blog (user website)
   •   twitter_username (sometimes populated)
It does not reliably include LinkedIn, Instagram, etc. If you want those, you need either:
   •   parse them from bio (fragile), or
   •   check the user’s pinned links manually (not available via the same endpoint), or
   •   add OAuth + additional sources (out of scope for a simple vanilla app).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions