chore: switch GitHub widget and changelog to Gitea
- Sidebar "GitHub" widget renamed to "Gitea", links point to gitea.giersig.eu/giersig.eu - Runtime widget JS fetches commits/repos/PRs directly from Gitea API - Build-time data files (githubActivity, githubRepos) switched from GitHub API to Gitea API - changelog.njk fetches from Gitea API directly with client-side commit categorisation - GITEA_URL / GITEA_ORG added to deploy.yml build env Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,298 +1,91 @@
|
||||
/**
|
||||
* GitHub Activity Data
|
||||
* Fetches from Indiekit's endpoint-github public API
|
||||
* Falls back to direct GitHub API if Indiekit is unavailable
|
||||
* Gitea Activity Data
|
||||
* Fetches commits and repos from self-hosted Gitea instance
|
||||
*/
|
||||
|
||||
import EleventyFetch from "@11ty/eleventy-fetch";
|
||||
|
||||
const GITHUB_USERNAME = process.env.GITHUB_USERNAME || "";
|
||||
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
|
||||
const GITEA_URL = process.env.GITEA_URL || "https://gitea.giersig.eu";
|
||||
const GITEA_ORG = process.env.GITEA_ORG || "giersig.eu";
|
||||
const GITEA_REPOS = (process.env.GITEA_REPOS || "indiekit-blog,indiekit-server").split(",").filter(Boolean);
|
||||
|
||||
// Fallback featured repos if Indiekit API unavailable (from env: comma-separated)
|
||||
const FALLBACK_FEATURED_REPOS = process.env.GITHUB_FEATURED_REPOS?.split(",").filter(Boolean) || [];
|
||||
|
||||
/**
|
||||
* Fetch from Indiekit's public GitHub API endpoint
|
||||
*/
|
||||
async function fetchFromIndiekit(endpoint) {
|
||||
const urls = [
|
||||
`${INDIEKIT_URL}/github/api/${endpoint}`,
|
||||
`${INDIEKIT_URL}/githubapi/api/${endpoint}`,
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
console.log(`[githubActivity] Fetching from Indiekit: ${url}`);
|
||||
const data = await EleventyFetch(url, {
|
||||
duration: "15m",
|
||||
type: "json",
|
||||
});
|
||||
console.log(`[githubActivity] Indiekit ${endpoint} success via ${url}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[githubActivity] Indiekit API unavailable for ${endpoint} at ${url}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from GitHub API directly
|
||||
*/
|
||||
async function fetchFromGitHub(endpoint) {
|
||||
const url = `https://api.github.com${endpoint}`;
|
||||
const headers = {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "Eleventy-Site",
|
||||
};
|
||||
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
return await EleventyFetch(url, {
|
||||
duration: "15m",
|
||||
type: "json",
|
||||
fetchOptions: { headers },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
function truncate(text, maxLength = 80) {
|
||||
if (!text || text.length <= maxLength) return text || "";
|
||||
return text.slice(0, maxLength - 1) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract commits from push events
|
||||
*/
|
||||
function extractCommits(events) {
|
||||
if (!Array.isArray(events)) return [];
|
||||
async function fetchGiteaCommits() {
|
||||
const allCommits = [];
|
||||
|
||||
// Style commit keywords
|
||||
const styleKeywords = [
|
||||
"style", "css", "tailwind", "design", "layout", "responsive", "dark mode", "typography", "color", "palette", "theme"
|
||||
];
|
||||
return events
|
||||
.filter((event) => event.type === "PushEvent")
|
||||
.flatMap((event) =>
|
||||
(event.payload?.commits || []).map((commit) => {
|
||||
const msg = commit.message.split("\n")[0];
|
||||
const lowerMsg = msg.toLowerCase();
|
||||
const isStyle = styleKeywords.some((kw) => lowerMsg.includes(kw));
|
||||
return {
|
||||
sha: commit.sha.slice(0, 7),
|
||||
message: truncate(msg),
|
||||
url: `https://github.com/${event.repo.name}/commit/${commit.sha}`,
|
||||
repo: event.repo.name,
|
||||
repoUrl: `https://github.com/${event.repo.name}`,
|
||||
date: event.created_at,
|
||||
category: isStyle ? "style" : undefined,
|
||||
};
|
||||
})
|
||||
)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract PRs/Issues from events
|
||||
*/
|
||||
function extractContributions(events) {
|
||||
if (!Array.isArray(events)) return [];
|
||||
|
||||
return events
|
||||
.filter(
|
||||
(event) =>
|
||||
(event.type === "PullRequestEvent" || event.type === "IssuesEvent") &&
|
||||
event.payload?.action === "opened"
|
||||
)
|
||||
.map((event) => {
|
||||
const item = event.payload.pull_request || event.payload.issue;
|
||||
return {
|
||||
type: event.type === "PullRequestEvent" ? "pr" : "issue",
|
||||
title: truncate(item?.title),
|
||||
url: item?.html_url,
|
||||
repo: event.repo.name,
|
||||
repoUrl: `https://github.com/${event.repo.name}`,
|
||||
number: item?.number,
|
||||
date: event.created_at,
|
||||
};
|
||||
})
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format starred repos
|
||||
*/
|
||||
function formatStarred(repos) {
|
||||
if (!Array.isArray(repos)) return [];
|
||||
|
||||
return repos.map((repo) => ({
|
||||
name: repo.full_name,
|
||||
description: truncate(repo.description, 120),
|
||||
url: repo.html_url,
|
||||
stars: repo.stargazers_count,
|
||||
language: repo.language,
|
||||
topics: repo.topics?.slice(0, 5) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch featured repos directly from GitHub (fallback)
|
||||
*/
|
||||
async function fetchFeaturedFromGitHub(repoList) {
|
||||
const featured = [];
|
||||
|
||||
for (const repoFullName of repoList) {
|
||||
for (const repo of GITEA_REPOS) {
|
||||
try {
|
||||
const repo = await fetchFromGitHub(`/repos/${repoFullName}`);
|
||||
let commits = [];
|
||||
try {
|
||||
const commitsData = await fetchFromGitHub(
|
||||
`/repos/${repoFullName}/commits?per_page=5`
|
||||
);
|
||||
commits = commitsData.map((c) => ({
|
||||
sha: c.sha.slice(0, 7),
|
||||
message: truncate(c.commit.message.split("\n")[0]),
|
||||
url: c.html_url,
|
||||
date: c.commit.author.date,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.log(`[githubActivity] Could not fetch commits for ${repoFullName}`);
|
||||
}
|
||||
const url = `${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${repo}/commits?limit=15`;
|
||||
console.log(`[giteaActivity] Fetching commits: ${url}`);
|
||||
const commits = await EleventyFetch(url, { duration: "15m", type: "json" });
|
||||
|
||||
featured.push({
|
||||
fullName: repo.full_name,
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
url: repo.html_url,
|
||||
stars: repo.stargazers_count,
|
||||
forks: repo.forks_count,
|
||||
language: repo.language,
|
||||
isPrivate: repo.private,
|
||||
commits,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`[githubActivity] Could not fetch ${repoFullName}: ${error.message}`);
|
||||
for (const c of Array.isArray(commits) ? commits : []) {
|
||||
const msg = (c.commit?.message || "").split("\n")[0];
|
||||
allCommits.push({
|
||||
sha: c.sha.slice(0, 7),
|
||||
message: truncate(msg),
|
||||
url: c.html_url,
|
||||
repo: `${GITEA_ORG}/${repo}`,
|
||||
repoUrl: `${GITEA_URL}/${GITEA_ORG}/${repo}`,
|
||||
date: c.created || c.commit?.author?.date,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[giteaActivity] Commits fetch failed for ${repo}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return featured;
|
||||
return allCommits.sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch commits directly from user's recently pushed repos
|
||||
* Fallback when events API doesn't include commit details
|
||||
*/
|
||||
async function fetchCommitsFromRepos(username, limit = 10) {
|
||||
async function fetchGiteaFeatured() {
|
||||
try {
|
||||
const repos = await fetchFromGitHub(
|
||||
`/users/${username}/repos?sort=pushed&per_page=5`
|
||||
);
|
||||
const url = `${GITEA_URL}/api/v1/orgs/${GITEA_ORG}/repos?limit=10&sort=newest`;
|
||||
const repos = await EleventyFetch(url, { duration: "15m", type: "json" });
|
||||
|
||||
if (!Array.isArray(repos) || repos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allCommits = [];
|
||||
for (const repo of repos.slice(0, 5)) {
|
||||
try {
|
||||
const repoCommits = await fetchFromGitHub(
|
||||
`/repos/${repo.full_name}/commits?per_page=5`
|
||||
);
|
||||
for (const c of repoCommits) {
|
||||
allCommits.push({
|
||||
sha: c.sha.slice(0, 7),
|
||||
message: truncate(c.commit?.message?.split("\n")[0]),
|
||||
url: c.html_url,
|
||||
repo: repo.full_name,
|
||||
repoUrl: repo.html_url,
|
||||
date: c.commit?.author?.date,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip repos we can't access
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date and limit
|
||||
return allCommits
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
.slice(0, limit);
|
||||
} catch (error) {
|
||||
console.log(`[githubActivity] Could not fetch commits from repos: ${error.message}`);
|
||||
return (Array.isArray(repos) ? repos : [])
|
||||
.filter((r) => !r.fork && !r.private)
|
||||
.slice(0, 5)
|
||||
.map((r) => ({
|
||||
fullName: r.full_name,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
url: r.html_url,
|
||||
stars: r.stars_count || r.stargazers_count || 0,
|
||||
forks: r.forks_count || 0,
|
||||
language: r.language,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.log(`[giteaActivity] Featured fetch failed: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function () {
|
||||
try {
|
||||
console.log("[githubActivity] Fetching GitHub data...");
|
||||
console.log("[giteaActivity] Fetching Gitea data...");
|
||||
|
||||
// Try Indiekit public API first
|
||||
const [indiekitStars, indiekitCommits, indiekitContributions, indiekitActivity, indiekitFeatured] =
|
||||
await Promise.all([
|
||||
fetchFromIndiekit("stars"),
|
||||
fetchFromIndiekit("commits"),
|
||||
fetchFromIndiekit("contributions"),
|
||||
fetchFromIndiekit("activity"),
|
||||
fetchFromIndiekit("featured"),
|
||||
]);
|
||||
|
||||
// Check if Indiekit API is available
|
||||
const hasIndiekitData =
|
||||
indiekitStars?.stars ||
|
||||
indiekitCommits?.commits ||
|
||||
indiekitFeatured?.featured;
|
||||
|
||||
if (hasIndiekitData) {
|
||||
console.log("[githubActivity] Using Indiekit API data");
|
||||
return {
|
||||
stars: indiekitStars?.stars || [],
|
||||
commits: indiekitCommits?.commits || [],
|
||||
contributions: indiekitContributions?.contributions || [],
|
||||
activity: indiekitActivity?.activity || [],
|
||||
featured: indiekitFeatured?.featured || [],
|
||||
source: "indiekit",
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to direct GitHub API
|
||||
console.log("[githubActivity] Falling back to GitHub API");
|
||||
|
||||
const [events, starred, featured] = await Promise.all([
|
||||
fetchFromGitHub(`/users/${GITHUB_USERNAME}/events/public?per_page=50`),
|
||||
fetchFromGitHub(`/users/${GITHUB_USERNAME}/starred?per_page=20&sort=created`),
|
||||
fetchFeaturedFromGitHub(FALLBACK_FEATURED_REPOS),
|
||||
const [commits, featured] = await Promise.all([
|
||||
fetchGiteaCommits(),
|
||||
fetchGiteaFeatured(),
|
||||
]);
|
||||
|
||||
// Try to extract commits from events first
|
||||
let commits = extractCommits(events || []);
|
||||
|
||||
// If events API didn't have commits, fetch directly from repos
|
||||
if (commits.length === 0 && GITHUB_USERNAME) {
|
||||
console.log("[githubActivity] Events API returned no commits, fetching from repos");
|
||||
commits = await fetchCommitsFromRepos(GITHUB_USERNAME, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
stars: formatStarred(starred || []),
|
||||
commits,
|
||||
contributions: extractContributions(events || []),
|
||||
stars: [],
|
||||
contributions: [],
|
||||
featured,
|
||||
source: "github",
|
||||
source: "gitea",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[githubActivity] Error:", error.message);
|
||||
console.error("[giteaActivity] Error:", error.message);
|
||||
return {
|
||||
stars: [],
|
||||
commits: [],
|
||||
stars: [],
|
||||
contributions: [],
|
||||
featured: [],
|
||||
source: "error",
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
/**
|
||||
* GitHub Repos Data
|
||||
* Fetches public repositories from GitHub API
|
||||
* Gitea Repos Data
|
||||
* Fetches public repositories from Gitea org API
|
||||
*/
|
||||
|
||||
import { cachedFetch } from "../lib/data-fetch.js";
|
||||
|
||||
export default async function () {
|
||||
const username = process.env.GITHUB_USERNAME || "";
|
||||
const GITEA_URL = process.env.GITEA_URL || "https://gitea.giersig.eu";
|
||||
const GITEA_ORG = process.env.GITEA_ORG || "giersig.eu";
|
||||
|
||||
export default async function () {
|
||||
try {
|
||||
// Fetch public repos, sorted by updated date
|
||||
const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`;
|
||||
const url = `${GITEA_URL}/api/v1/orgs/${GITEA_ORG}/repos?limit=10&sort=newest`;
|
||||
|
||||
const repos = await cachedFetch(url, {
|
||||
duration: "1h", // Cache for 1 hour
|
||||
duration: "1h",
|
||||
type: "json",
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "Eleventy-Site",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Filter and transform repos
|
||||
return repos
|
||||
.filter((repo) => !repo.fork && !repo.private) // Exclude forks and private repos
|
||||
.filter((repo) => !repo.fork && !repo.private)
|
||||
.map((repo) => ({
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
description: repo.description,
|
||||
html_url: repo.html_url,
|
||||
homepage: repo.homepage,
|
||||
homepage: repo.website || repo.homepage,
|
||||
language: repo.language,
|
||||
stargazers_count: repo.stargazers_count,
|
||||
forks_count: repo.forks_count,
|
||||
open_issues_count: repo.open_issues_count,
|
||||
stargazers_count: repo.stars_count || repo.stargazers_count || 0,
|
||||
forks_count: repo.forks_count || 0,
|
||||
open_issues_count: repo.open_issues_count || 0,
|
||||
topics: repo.topics || [],
|
||||
updated_at: repo.updated_at,
|
||||
created_at: repo.created_at,
|
||||
updated_at: repo.updated,
|
||||
created_at: repo.created,
|
||||
}))
|
||||
.slice(0, 10); // Limit to 10 repos
|
||||
.slice(0, 10);
|
||||
} catch (error) {
|
||||
console.error("Error fetching GitHub repos:", error.message);
|
||||
console.error("Error fetching Gitea repos:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,13 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
// Gitea configuration
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL || "https://gitea.giersig.eu",
|
||||
org: process.env.GITEA_ORG || "giersig.eu",
|
||||
repos: (process.env.GITEA_REPOS || "indiekit-blog,indiekit-server").split(",").filter(Boolean),
|
||||
},
|
||||
|
||||
// Webmentions configuration
|
||||
webmentions: {
|
||||
domain: process.env.SITE_URL?.replace("https://", "").replace("http://", "") || "example.com",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{# Resolve widget title #}
|
||||
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
|
||||
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
|
||||
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
|
||||
{% elif widget.type == "github-repos" %}{% set widgetTitle = "Gitea" %}
|
||||
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
|
||||
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
|
||||
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{# Resolve widget title #}
|
||||
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
|
||||
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
|
||||
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
|
||||
{% elif widget.type == "github-repos" %}{% set widgetTitle = "Gitea" %}
|
||||
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
|
||||
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
|
||||
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{# Resolve widget title #}
|
||||
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
|
||||
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
|
||||
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
|
||||
{% elif widget.type == "github-repos" %}{% set widgetTitle = "Gitea" %}
|
||||
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
|
||||
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
|
||||
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
|
||||
@@ -206,7 +206,7 @@
|
||||
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
|
||||
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-surface-400 dark:border-l-surface-500">
|
||||
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
|
||||
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("github", "w-5 h-5 text-surface-800 dark:text-surface-200") }} GitHub</h3>
|
||||
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("github", "w-5 h-5 text-surface-800 dark:text-surface-200") }} Gitea</h3>
|
||||
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
{# GitHub Activity Widget - Tabbed Commits/Repos/Featured/PRs with live API data #}
|
||||
{# Gitea Activity Widget - Tabbed Commits/Repos/Featured/PRs #}
|
||||
<is-land on:visible>
|
||||
{% set ghFallbackCommits = githubActivity.commits if githubActivity and githubActivity.commits else [] %}
|
||||
{% set ghFallbackFeatured = githubActivity.featured if githubActivity and githubActivity.featured else [] %}
|
||||
{% set ghFallbackContributions = githubActivity.contributions if githubActivity and githubActivity.contributions else [] %}
|
||||
{% set ghFallbackRepos = githubRepos if githubRepos else [] %}
|
||||
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
|
||||
{% set socialLinks = id.social if (id.social is defined) else site.social %}
|
||||
{% set githubProfileUrl = "" %}
|
||||
{% for link in socialLinks %}
|
||||
{% if not githubProfileUrl and (link.icon == "github" or "github.com/" in link.url) %}
|
||||
{% set githubProfileUrl = link.url %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not githubProfileUrl and site.feeds.github %}
|
||||
{% set githubProfileUrl = "https://github.com/" + site.feeds.github %}
|
||||
{% endif %}
|
||||
<div class="widget" x-data="githubWidget('{{ site.feeds.github }}', '{{ githubProfileUrl }}')" x-init="init()">
|
||||
{% set giteaProfileUrl = site.gitea.url + "/" + site.gitea.org %}
|
||||
<div class="widget" x-data="giteaWidget('{{ site.gitea.url }}', '{{ site.gitea.org }}', '{{ site.gitea.repos | join(",") }}')" x-init="init()">
|
||||
<h3 class="widget-title flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
GitHub
|
||||
Gitea
|
||||
</h3>
|
||||
|
||||
{# Tab buttons — order: Commits, Repos, Featured, PRs #}
|
||||
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700" role="tablist" aria-label="GitHub activity">
|
||||
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700" role="tablist" aria-label="Gitea activity">
|
||||
<button
|
||||
@click="activeTab = 'commits'"
|
||||
:class="activeTab === 'commits' ? 'border-b-2 border-accent-500 text-accent-700 dark:text-accent-300' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||
@@ -151,11 +141,9 @@
|
||||
<a :href="item.url" target="_blank" rel="noopener" class="block group">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center"
|
||||
:class="item.type === 'pr' ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400' : 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400'"
|
||||
class="flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400"
|
||||
>
|
||||
<svg x-show="item.type === 'pr'" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<svg x-show="item.type === 'issue'" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke-width="2"/><line x1="12" y1="8" x2="12" y2="12" stroke-width="2"/><line x1="12" y1="16" x2="12.01" y2="16" stroke-width="2"/></svg>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
</span>
|
||||
<span class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-surface-900 dark:group-hover:text-surface-100 transition-colors truncate" x-text="item.title"></span>
|
||||
</div>
|
||||
@@ -168,18 +156,16 @@
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div x-show="contributions.length === 0" class="text-sm text-surface-600 dark:text-surface-400 py-2">No recent PRs or issues.</div>
|
||||
<div x-show="contributions.length === 0" class="text-sm text-surface-600 dark:text-surface-400 py-2">No recent PRs.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# Footer link #}
|
||||
{% if githubProfileUrl %}
|
||||
<a href="{{ githubProfileUrl }}" target="_blank" rel="noopener" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View on GitHub
|
||||
<a href="{{ giteaProfileUrl }}" target="_blank" rel="noopener" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-3 inline-flex items-center gap-1">
|
||||
View on Gitea
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -190,7 +176,9 @@ const githubFallbackData = {
|
||||
repos: {{ ghFallbackRepos | dump | safe }},
|
||||
};
|
||||
|
||||
function githubWidget(username, profileUrl) {
|
||||
function giteaWidget(giteaUrl, giteaOrg, reposStr) {
|
||||
const repoList = reposStr.split(',').filter(Boolean);
|
||||
|
||||
return {
|
||||
activeTab: 'commits',
|
||||
loading: true,
|
||||
@@ -199,108 +187,98 @@ function githubWidget(username, profileUrl) {
|
||||
featured: [],
|
||||
contributions: [],
|
||||
|
||||
sanitizeRepos(repos) {
|
||||
if (!Array.isArray(repos)) return [];
|
||||
return repos.filter((repo) => !repo.fork && !repo.private);
|
||||
},
|
||||
|
||||
deriveUsernameFromCommits(commits) {
|
||||
if (!Array.isArray(commits) || commits.length === 0) return '';
|
||||
const firstRepo = commits.find((item) => item && item.repo)?.repo || '';
|
||||
return firstRepo.includes('/') ? firstRepo.split('/')[0] : '';
|
||||
},
|
||||
|
||||
deriveUsernameFromProfile(url) {
|
||||
if (!url || typeof url !== 'string') return '';
|
||||
const match = url.match(/github\.com\/([^/?#]+)/i);
|
||||
return match ? match[1] : '';
|
||||
},
|
||||
|
||||
async fetchJson(paths) {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const response = await fetch(path);
|
||||
if (response.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
data: await response.json(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Try next candidate path.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
data: null,
|
||||
};
|
||||
},
|
||||
|
||||
async fetchReposForUser(user) {
|
||||
if (!user) return [];
|
||||
|
||||
async fetchJson(url) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.github.com/users/' + user + '/repos?sort=updated&per_page=10&type=owner',
|
||||
{
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
return this.sanitizeRepos(await response.json());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const r = await fetch(url);
|
||||
if (r.ok) return await r.json();
|
||||
} catch {}
|
||||
return null;
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.commits = Array.isArray(githubFallbackData.commits) ? githubFallbackData.commits : [];
|
||||
this.featured = Array.isArray(githubFallbackData.featured) ? githubFallbackData.featured : [];
|
||||
this.contributions = Array.isArray(githubFallbackData.contributions) ? githubFallbackData.contributions : [];
|
||||
this.repos = this.sanitizeRepos(githubFallbackData.repos);
|
||||
this.repos = (githubFallbackData.repos || []).filter((r) => !r.fork && !r.private);
|
||||
|
||||
const hasFallbackData =
|
||||
this.commits.length > 0 ||
|
||||
this.featured.length > 0 ||
|
||||
this.contributions.length > 0 ||
|
||||
this.repos.length > 0;
|
||||
|
||||
this.loading = !hasFallbackData;
|
||||
|
||||
try {
|
||||
const [commitsRes, featuredRes, contribRes] = await Promise.all([
|
||||
this.fetchJson(['/github/api/commits', '/githubapi/api/commits']),
|
||||
this.fetchJson(['/github/api/featured', '/githubapi/api/featured']),
|
||||
this.fetchJson(['/github/api/contributions', '/githubapi/api/contributions']),
|
||||
]);
|
||||
|
||||
if (commitsRes.ok) {
|
||||
this.commits = commitsRes.data?.commits || [];
|
||||
}
|
||||
if (featuredRes.ok) {
|
||||
this.featured = featuredRes.data?.featured || [];
|
||||
}
|
||||
if (contribRes.ok) {
|
||||
this.contributions = contribRes.data?.contributions || [];
|
||||
// Fetch commits from all repos
|
||||
const commitArrays = await Promise.all(
|
||||
repoList.map((repo) =>
|
||||
this.fetchJson(`${giteaUrl}/api/v1/repos/${giteaOrg}/${repo}/commits?limit=10`)
|
||||
)
|
||||
);
|
||||
const allCommits = commitArrays.flatMap((commits, i) => {
|
||||
if (!Array.isArray(commits)) return [];
|
||||
return commits.map((c) => ({
|
||||
sha: c.sha.slice(0, 7),
|
||||
message: (c.commit?.message || '').split('\n')[0],
|
||||
url: c.html_url,
|
||||
repo: `${giteaOrg}/${repoList[i]}`,
|
||||
date: c.created || c.commit?.author?.date,
|
||||
}));
|
||||
});
|
||||
if (allCommits.length > 0) {
|
||||
this.commits = allCommits
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
let resolvedUsername = this.deriveUsernameFromProfile(profileUrl);
|
||||
if (!resolvedUsername) {
|
||||
resolvedUsername = username;
|
||||
}
|
||||
if (!resolvedUsername) {
|
||||
resolvedUsername = this.deriveUsernameFromCommits(this.commits);
|
||||
// Fetch repos
|
||||
const repos = await this.fetchJson(`${giteaUrl}/api/v1/orgs/${giteaOrg}/repos?limit=10&sort=newest`);
|
||||
if (Array.isArray(repos)) {
|
||||
const filtered = repos.filter((r) => !r.fork && !r.private);
|
||||
this.repos = filtered.map((r) => ({
|
||||
name: r.name,
|
||||
full_name: r.full_name,
|
||||
description: r.description,
|
||||
html_url: r.html_url,
|
||||
language: r.language,
|
||||
stargazers_count: r.stars_count || r.stargazers_count || 0,
|
||||
updated_at: r.updated,
|
||||
}));
|
||||
this.featured = filtered.slice(0, 5).map((r) => ({
|
||||
fullName: r.full_name,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
url: r.html_url,
|
||||
stars: r.stars_count || r.stargazers_count || 0,
|
||||
forks: r.forks_count || 0,
|
||||
language: r.language,
|
||||
}));
|
||||
}
|
||||
|
||||
const repos = await this.fetchReposForUser(resolvedUsername);
|
||||
if (repos.length > 0 || this.repos.length === 0) {
|
||||
this.repos = repos;
|
||||
// Fetch PRs
|
||||
const prArrays = await Promise.all(
|
||||
repoList.map((repo) =>
|
||||
this.fetchJson(`${giteaUrl}/api/v1/repos/${giteaOrg}/${repo}/pulls?state=closed&limit=5&type=pulls`)
|
||||
)
|
||||
);
|
||||
const allPRs = prArrays.flatMap((prs, i) => {
|
||||
if (!Array.isArray(prs)) return [];
|
||||
return prs.map((pr) => ({
|
||||
type: 'pr',
|
||||
title: pr.title,
|
||||
url: pr.html_url,
|
||||
repo: `${giteaOrg}/${repoList[i]}`,
|
||||
number: pr.number,
|
||||
date: pr.merged_at || pr.closed_at || pr.created_at,
|
||||
}));
|
||||
});
|
||||
if (allPRs.length > 0) {
|
||||
this.contributions = allPRs
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
.slice(0, 10);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('GitHub widget error:', err);
|
||||
console.error('Gitea widget error:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,20 @@ withSidebar: false
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const GITEA_URL = '{{ site.gitea.url }}';
|
||||
const GITEA_ORG = '{{ site.gitea.org }}';
|
||||
const GITEA_REPOS = {{ site.gitea.repos | dump | safe }};
|
||||
|
||||
function categorizeCommit(message) {
|
||||
const lower = (message || '').toLowerCase();
|
||||
if (/^feat(\(.+\))?!?:/.test(lower)) return 'features';
|
||||
if (/^fix(\(.+\))?!?:/.test(lower)) return 'fixes';
|
||||
if (/^docs?(\(.+\))?!?:/.test(lower)) return 'documentation';
|
||||
if (/^(chore|build|ci|style)(\(.+\))?!?:/.test(lower)) return 'chores';
|
||||
if (/^(refactor|perf|test|a11y)(\(.+\))?!?:/.test(lower)) return 'refactor';
|
||||
return 'chores';
|
||||
}
|
||||
|
||||
function changelogApp() {
|
||||
return {
|
||||
activeTab: 'all',
|
||||
@@ -152,12 +166,53 @@ function changelogApp() {
|
||||
|
||||
async fetchChangelog(days) {
|
||||
try {
|
||||
const response = await fetch('/github/api/changelog?days=' + days);
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
const data = await response.json();
|
||||
this.commits = data.commits || [];
|
||||
this.categories = data.categories || {};
|
||||
this.currentDays = data.days;
|
||||
const since = days === 'all'
|
||||
? null
|
||||
: new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const allCommits = [];
|
||||
await Promise.all(GITEA_REPOS.map(async (repo) => {
|
||||
let page = 1;
|
||||
const limit = 50;
|
||||
while (true) {
|
||||
let url = `${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${repo}/commits?limit=${limit}&page=${page}`;
|
||||
if (since) url += `&since=${since}`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) break;
|
||||
const commits = await r.json();
|
||||
if (!Array.isArray(commits) || commits.length === 0) break;
|
||||
for (const c of commits) {
|
||||
const lines = (c.commit?.message || '').split('\n');
|
||||
const title = lines[0];
|
||||
const body = lines.slice(1).join('\n').trim();
|
||||
allCommits.push({
|
||||
sha: c.sha.slice(0, 7),
|
||||
fullSha: c.sha,
|
||||
title,
|
||||
body: body || null,
|
||||
url: c.html_url,
|
||||
repoUrl: `${GITEA_URL}/${GITEA_ORG}/${repo}`,
|
||||
repoName: repo,
|
||||
date: c.created || c.commit?.author?.date,
|
||||
author: c.commit?.author?.name || '',
|
||||
commitCategory: categorizeCommit(title),
|
||||
});
|
||||
}
|
||||
if (commits.length < limit) break;
|
||||
page++;
|
||||
}
|
||||
}));
|
||||
|
||||
allCommits.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
|
||||
const categories = {};
|
||||
for (const c of allCommits) {
|
||||
categories[c.commitCategory] = (categories[c.commitCategory] || 0) + 1;
|
||||
}
|
||||
|
||||
this.commits = allCommits;
|
||||
this.categories = categories;
|
||||
this.currentDays = days;
|
||||
} catch (err) {
|
||||
console.error('Changelog error:', err);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user