diff --git a/v8/github_metadata/README.md b/v8/github_metadata/README.md new file mode 100644 index 00000000..aeb0d2e8 --- /dev/null +++ b/v8/github_metadata/README.md @@ -0,0 +1,141 @@ +# github_metadata (Nikola plugin) + +Expose a `github` namespace to Nikola templates, similar to `site.github` in +jekyll-github-metadata. The plugin can fetch public repos for a user, or use a +manual list defined in `conf.py`. + +![Github Metadata demo](github_metadata.png) + +## Install + +Copy the plugin folder into your Nikola site: + +```text +your_site/ + plugins/ + github_metadata/ + github_metadata.py + github_metadata.plugin + README.md + conf.py.sample + requirements.txt +``` + +## Plugin files + +- `README.md` (this file) +- `github_metadata.py` (plugin code) +- `github_metadata.plugin` (Nikola/Yapsy metadata) +- `conf.py.sample` (sample configuration) +- `requirements.txt` (Python dependencies, currently `certifi`) + +## Configuration (conf.py) + +Minimal config (public repos for one user): + +```python +GITHUB_METADATA = { + "public_repositories": { + "enabled": True, + "user": "your_github_user", + } +} +``` + +Full config (API + cache + filters, unauthenticated): + +```python +GITHUB_METADATA = { + "enabled": True, + "inject_as": "github", + "api_url": "https://api.github.com", + "cache_ttl": 3600, + + # Optional: add github.repository + # "repository": "owner/repo", + + "public_repositories": { + "enabled": True, + "user": "your_github_user", + "sort": "pushed", + "direction": "desc", + "include_forks": False, + "include_archived": False, + "limit": 200, + }, +} +``` + +Manual repos (no API call): + +```python +GITHUB_METADATA = { + "public_repositories": { + "enabled": False, + "user": "your_github_user", + }, + "manual_repositories": [ + "owner/repo-1", + "repo-2", # will use user as owner + { + "name": "repo-3", + "full_name": "owner/repo-3", + "html_url": "https://github.com/owner/repo-3", + "description": "Short description", + "language": "Python", + "stargazers_count": 0, + }, + ], +} +``` + +## Template usage (Jinja) + +```jinja +{% if github.public_repositories %} + +{% endif %} +``` + +Optional single repo: + +```jinja +{% if github.repository %} + {{ github.repository.full_name }} +{% endif %} +``` + +`github.repository` is fetched only when `GITHUB_METADATA["repository"]` is set. + +## What is injected + +The plugin injects a dictionary under `GITHUB_METADATA["inject_as"]` (default +`github`) with: + +- `api_url` +- `user_login` +- `repository_nwo` (only when `repository` is configured) +- `public_repositories` (list) +- `repository` (single repo, optional) +- `errors` (list of strings) +- `generated_at` (epoch seconds) + +## Online example + +- The following website use the Github Metadata plugin : [stephmnt/datascience_portfolio](stephmnt.github.io/datascience_portfolio) + +## The repository + +- [https://github.com/stephmnt/GitHub_metadata](https://github.com/stephmnt/GitHub_metadata) + +## Notes + +- The GitHub API is used when `public_repositories.enabled` is True. +- This plugin does not use tokens by design; any `token` setting is ignored. +- Cached responses are stored under `cache/` and expire after `cache_ttl`. +- `certifi` is installed via `requirements.txt` to improve SSL reliability. +- If you hit rate limits, use `manual_repositories` to avoid API calls. diff --git a/v8/github_metadata/conf.py.sample b/v8/github_metadata/conf.py.sample new file mode 100644 index 00000000..76f385b6 --- /dev/null +++ b/v8/github_metadata/conf.py.sample @@ -0,0 +1,27 @@ +# github_metadata plugin sample configuration + +GITHUB_METADATA = { + "enabled": True, + "inject_as": "github", + "api_url": "https://api.github.com", + "cache_ttl": 3600, + + # Optional: add github.repository + # "repository": "owner/repo", + + "public_repositories": { + "enabled": True, + "user": "your_github_user", + "sort": "pushed", + "direction": "desc", + "include_forks": False, + "include_archived": False, + "limit": 200, + }, + + # Optional: manual repos list (disables API if provided) + # "manual_repositories": [ + # "owner/repo-1", + # "repo-2", + # ], +} diff --git a/v8/github_metadata/github_metadata.plugin b/v8/github_metadata/github_metadata.plugin new file mode 100644 index 00000000..c4593981 --- /dev/null +++ b/v8/github_metadata/github_metadata.plugin @@ -0,0 +1,13 @@ +[Core] +Name = github_metadata +Module = github_metadata + +[Documentation] +Author = Stéphane Manet +Version = 0.1.0 +Website = https://plugins.getnikola.com/#github_metadata +Description = Expose GitHub user public repositories to Nikola templates (jekyll-github-metadata-like). + +[Nikola] +PluginCategory = SignalHandler +MinVersion = 8.0.0 diff --git a/v8/github_metadata/github_metadata.png b/v8/github_metadata/github_metadata.png new file mode 100644 index 00000000..1fcdc9f6 Binary files /dev/null and b/v8/github_metadata/github_metadata.png differ diff --git a/v8/github_metadata/github_metadata.py b/v8/github_metadata/github_metadata.py new file mode 100644 index 00000000..2beaa5b1 --- /dev/null +++ b/v8/github_metadata/github_metadata.py @@ -0,0 +1,474 @@ +# -*- coding: utf-8 -*- +# +# MIT License +# +# Copyright (c) 2026 Stephane Manet +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import json +import os +import re +import ssl +import time +import subprocess +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse +from urllib.error import URLError +from urllib.request import Request, urlopen + +from blinker import signal +from nikola.plugin_categories import SignalHandler # type: ignore + +try: + import certifi +except ImportError: + certifi = None + + +# ---------------------------- +# Settings +# ---------------------------- + +@dataclass(frozen=True) +class PublicReposSettings: + enabled: bool = True + user: Optional[str] = None # GitHub login (user) + sort: str = "pushed" # created, updated, pushed, full_name + direction: str = "desc" # asc, desc + include_forks: bool = False + include_archived: bool = False + limit: int = 200 # safety limit + + +@dataclass(frozen=True) +class Settings: + enabled: bool = True + inject_as: str = "github" + api_url: str = "https://api.github.com" + cache_ttl: int = 3600 # seconds + + # Optional: for parity (single repo metadata) + repository: Optional[str] = None # "owner/repo" (optional) + + public_repositories: PublicReposSettings = PublicReposSettings() + manual_repositories: Optional[List[Dict[str, Any]]] = None + + +# ---------------------------- +# Plugin +# ---------------------------- + +class GithubMetadata(SignalHandler): + """Inject GitHub metadata into GLOBAL_CONTEXT for templates.""" + + name = "github_metadata" + + def set_site(self, site): + super().set_site(site) + signal("configured").connect(self._on_configured, sender=site) + + def _on_configured(self, site): + raw_cfg = site.config.get("GITHUB_METADATA") or {} + s = _parse_settings(raw_cfg) + + if not s.enabled: + self.logger.info("github_metadata: disabled.") + return + + cache_dir = _cache_dir(site) + + # Determine repo (optional) and user (for public repos) + detected_repo_nwo = _detect_repo_nwo(site) + repo_nwo = s.repository + owner_source = repo_nwo or detected_repo_nwo + detected_owner = owner_source.split("/", 1)[0] if owner_source and "/" in owner_source else None + manual_repos = _normalize_manual_repos(s.manual_repositories, s.public_repositories.user) + manual_owner = _detect_owner_from_repos(manual_repos) + + user_login = s.public_repositories.user or manual_owner or detected_owner or os.environ.get("GITHUB_ACTOR") + if not user_login and not manual_repos: + self.logger.warning( + "github_metadata: cannot determine GitHub user. " + "Set GITHUB_METADATA['public_repositories']['user']." + ) + return + if not user_login: + user_login = "manual" + + if raw_cfg.get("token"): + self.logger.warning( + "github_metadata: token is ignored; this plugin uses only unauthenticated requests." + ) + + client = _GitHubClient(api_url=s.api_url, logger=self.logger) + + github_obj: Dict[str, Any] = { + "api_url": s.api_url, + "user_login": user_login, + "repository_nwo": repo_nwo, + "public_repositories": [], + "repository": None, + "errors": [], + "generated_at": int(time.time()), + } + + # 1) Fetch public repositories (main goal) + if manual_repos: + github_obj["public_repositories"] = manual_repos + elif s.public_repositories.enabled: + cache_key = _public_repos_cache_key(user_login, s.public_repositories) + cache_path = os.path.join(cache_dir, cache_key) + + repos = _read_cache_json(cache_path, ttl=s.cache_ttl) + if repos is None: + try: + repos = client.list_user_public_repos( + user=user_login, + sort=s.public_repositories.sort, + direction=s.public_repositories.direction, + limit=s.public_repositories.limit, + ) + repos = _filter_repos( + repos, + include_forks=s.public_repositories.include_forks, + include_archived=s.public_repositories.include_archived, + ) + _write_cache_json(cache_path, repos) + except Exception as e: + github_obj["errors"].append(f"public_repositories: {e!r}") + # fallback to stale cache if present + repos = _read_cache_json_any(cache_path) or [] + + github_obj["public_repositories"] = repos + + # 2) Optional: single repo metadata (nice-to-have parity) + if repo_nwo: + cache_path = os.path.join(cache_dir, _repo_cache_key(repo_nwo)) + repo_obj = _read_cache_json(cache_path, ttl=s.cache_ttl) + if repo_obj is None: + try: + repo_obj = client.get_repo(repo_nwo) + _write_cache_json(cache_path, repo_obj) + except Exception as e: + github_obj["errors"].append(f"repository: {e!r}") + repo_obj = _read_cache_json_any(cache_path) + + github_obj["repository"] = repo_obj + + # Inject into GLOBAL_CONTEXT (templates) + gc = site.config.setdefault("GLOBAL_CONTEXT", {}) + gc[s.inject_as] = github_obj + + # Some Nikola versions keep a copy — harmless best-effort: + for attr in ("GLOBAL_CONTEXT", "_GLOBAL_CONTEXT"): + try: + obj = getattr(site, attr) + if isinstance(obj, dict): + obj[s.inject_as] = github_obj + except Exception: + pass + + self.logger.info( + f"github_metadata: injected '{s.inject_as}' (user={user_login}, " + f"repos={len(github_obj['public_repositories'])})." + ) + if github_obj["errors"]: + self.logger.warning( + "github_metadata: errors: %s", "; ".join(github_obj["errors"]) + ) + + +# ---------------------------- +# Settings parsing +# ---------------------------- + +def _parse_settings(cfg: Dict[str, Any]) -> Settings: + pr_cfg = (cfg.get("public_repositories") or {}) + pr = PublicReposSettings( + enabled=bool(pr_cfg.get("enabled", True)), + user=pr_cfg.get("user"), + sort=pr_cfg.get("sort", "pushed"), + direction=pr_cfg.get("direction", "desc"), + include_forks=bool(pr_cfg.get("include_forks", False)), + include_archived=bool(pr_cfg.get("include_archived", False)), + limit=int(pr_cfg.get("limit", 200)), + ) + manual_repositories = cfg.get("manual_repositories") or None + + return Settings( + enabled=bool(cfg.get("enabled", True)), + inject_as=cfg.get("inject_as", "github"), + api_url=cfg.get("api_url", "https://api.github.com"), + cache_ttl=int(cfg.get("cache_ttl", 3600)), + repository=cfg.get("repository"), + public_repositories=pr, + manual_repositories=manual_repositories, + ) + + + +# ---------------------------- +# GitHub client (urllib, no requests) +# ---------------------------- + +class _GitHubClient: + def __init__(self, api_url: str, logger): + self.api_url = api_url.rstrip("/") + self.logger = logger + + def get_repo(self, repo_nwo: str) -> Dict[str, Any]: + owner, repo = repo_nwo.split("/", 1) + return self._get_json(f"/repos/{owner}/{repo}")[0] + + def list_user_public_repos( + self, + user: str, + sort: str = "pushed", + direction: str = "desc", + limit: int = 200, + ) -> List[Dict[str, Any]]: + # GitHub API: GET /users/{username}/repos?type=public&per_page=100&sort=...&direction=... + path = f"/users/{user}/repos?type=public&per_page=100&sort={sort}&direction={direction}" + items: List[Dict[str, Any]] = [] + next_url: Optional[str] = self.api_url + path + + while next_url and len(items) < limit: + data, headers = self._get_json_abs(next_url) + if isinstance(data, list): + items.extend(data) + else: + break + + next_url = _parse_next_link(headers.get("Link")) + return items[:limit] + + def _get_json(self, path: str) -> Tuple[Any, Dict[str, str]]: + return self._get_json_abs(self.api_url + path) + + def _get_json_abs(self, url: str) -> Tuple[Any, Dict[str, str]]: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "nikola-github-metadata/0.2.0", + } + + req = Request(url, headers=headers, method="GET") + context = None + if certifi is not None: + context = ssl.create_default_context(cafile=certifi.where()) + try: + with urlopen(req, timeout=20, context=context) as resp: + raw = resp.read().decode("utf-8", errors="replace") + + if resp.status == 403 and "rate limit" in raw.lower(): + raise RuntimeError("GitHub API rate limit exceeded (unauthenticated)") + + if resp.status >= 400: + raise RuntimeError(f"GET {url} -> {resp.status}: {raw[:200]}") + + data = json.loads(raw) + hdrs = {k: v for (k, v) in resp.headers.items()} + return data, hdrs + except URLError as exc: + reason = getattr(exc, "reason", None) + if isinstance(reason, ssl.SSLCertVerificationError): + raise RuntimeError( + "SSL certificate verification failed. On macOS, run the " + "Python 'Install Certificates.command' or install certifi." + ) from exc + raise + + + +def _parse_next_link(link_header: Optional[str]) -> Optional[str]: + # Link: <...>; rel="next", <...>; rel="last" + if not link_header: + return None + for part in link_header.split(","): + part = part.strip() + if 'rel="next"' in part: + m = re.search(r"<([^>]+)>", part) + if m: + return m.group(1) + return None + + +# ---------------------------- +# Repo detection (optional) +# ---------------------------- + +def _detect_repo_nwo(site) -> Optional[str]: + env = os.environ.get("GITHUB_REPOSITORY") + if env and "/" in env: + return env.strip() + + base = site.config.get("BASE_FOLDER") or os.getcwd() + try: + remote = subprocess.check_output( + ["git", "config", "--get", "remote.origin.url"], + cwd=base, + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except Exception: + return None + + return _parse_repo_from_remote(remote) + + +def _parse_repo_from_remote(remote: str) -> Optional[str]: + remote = remote.strip() + + m = re.match(r"git@[^:]+:(?P[^/]+)/(?P[^/]+?)(?:\.git)?$", remote) + if m: + return f"{m.group('owner')}/{m.group('repo')}" + + if remote.startswith(("http://", "https://", "ssh://")): + u = urlparse(remote) + path = (u.path or "").lstrip("/") + if path.endswith(".git"): + path = path[:-4] + if path.count("/") >= 1: + owner, repo = path.split("/", 1) + return f"{owner}/{repo}" + + return None + + +# ---------------------------- +# Filtering + caching +# ---------------------------- + +def _filter_repos( + repos: List[Dict[str, Any]], + include_forks: bool, + include_archived: bool, +) -> List[Dict[str, Any]]: + out = [] + for r in repos: + if (not include_forks) and r.get("fork"): + continue + if (not include_archived) and r.get("archived"): + continue + out.append(r) + return out + + +def _normalize_manual_repos( + repos: Optional[List[Any]], + default_owner: Optional[str], +) -> List[Dict[str, Any]]: + if not repos: + return [] + out: List[Dict[str, Any]] = [] + for item in repos: + if isinstance(item, str): + name = item.strip() + if not name: + continue + if "/" in name: + owner, repo_name = name.split("/", 1) + else: + owner, repo_name = default_owner, name + repo: Dict[str, Any] = {"name": repo_name} + if owner: + full_name = f"{owner}/{repo_name}" + repo["full_name"] = full_name + repo["html_url"] = f"https://github.com/{full_name}" + else: + repo["full_name"] = name + out.append(repo) + continue + + if isinstance(item, dict): + repo = dict(item) + full_name = repo.get("full_name") + name = repo.get("name") + if not name and full_name and "/" in full_name: + repo["name"] = full_name.split("/", 1)[1] + name = repo["name"] + if not full_name and name and default_owner: + repo["full_name"] = f"{default_owner}/{name}" + full_name = repo["full_name"] + if "html_url" not in repo and full_name and "/" in full_name: + repo["html_url"] = f"https://github.com/{full_name}" + out.append(repo) + + return out + + +def _detect_owner_from_repos(repos: List[Dict[str, Any]]) -> Optional[str]: + for repo in repos: + full_name = repo.get("full_name") + if full_name and "/" in full_name: + return full_name.split("/", 1)[0] + return None + + +def _cache_dir(site) -> str: + d = site.config.get("CACHE_FOLDER") + if not d: + base = site.config.get("BASE_FOLDER") or "." + d = os.path.join(base, "cache") + os.makedirs(d, exist_ok=True) + return d + + +def _public_repos_cache_key(user: str, pr: PublicReposSettings) -> str: + # include sort/direction + filters in filename to avoid mixing caches + safe_user = user.replace("/", "_") + return ( + f"github_metadata__public_repos__{safe_user}" + f"__sort-{pr.sort}__dir-{pr.direction}" + f"__forks-{int(pr.include_forks)}__arch-{int(pr.include_archived)}.json" + ) + + +def _repo_cache_key(repo_nwo: str) -> str: + safe = repo_nwo.replace("/", "__") + return f"github_metadata__repo__{safe}.json" + + +def _read_cache_json(path: str, ttl: int) -> Optional[Any]: + try: + st = os.stat(path) + if (time.time() - st.st_mtime) <= ttl: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + return None + + +def _read_cache_json_any(path: str) -> Optional[Any]: + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + + +def _write_cache_json(path: str, data: Any) -> None: + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) + os.replace(tmp, path) diff --git a/v8/github_metadata/requirements.txt b/v8/github_metadata/requirements.txt new file mode 100644 index 00000000..963eac53 --- /dev/null +++ b/v8/github_metadata/requirements.txt @@ -0,0 +1 @@ +certifi