-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Below is a clean, production-grade pattern that matches your app (HTML/SCSS/vanilla JS, animated UI, observer transitions).
⸻
- “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.
⸻
- 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.
⸻
- 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.
⸻
- 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);
});
⸻
- 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).
⸻