# Projects Portfolio — Design A developer-first showcase directory for WordPress plugins, themes, and patterns. Each "Project" post links to a GitHub or Gitea repository; the plugin pulls repo metadata (version, stars, forks, license, language, release ZIP) on demand and renders a per-project detail page. --- ## 1. Goals - A `projects` custom post type and `project-type` taxonomy — a WordPress-native showcase. - A metabox on each project that captures a Provider (GitHub or Gitea), the repo URL, and (for Gitea) a per-project base URL override. - A normalized data model so templates and the REST endpoint never branch on the underlying host. - A `/download/{id}/` endpoint that redirects to the release ZIP on the upstream host and increments a local counter. - Gitea parity with GitHub for the common metadata fields (stars, forks, issues, license, language, version) — with provider-agnostic display. - A settings page that exposes a Gitea base URL + token, template toggle fields, and archive toggle fields. - Works on both Gitea Actions and GitHub Actions runners (single workflow file with host-branching). ## 2. Non-goals - Provider-agnostic **write** operations (no plugin editing Gitea or GitHub on the user's behalf). - Multi-repo rendering on a single Project post (one project = one repo). - Cross-provider migration tools — adding a new project is per-provider manual. - WordPress.org plugin distribution (the Gitea release zip is the deliverable). - Browsing Gitea organizations or selecting from a list — the repo URL is user-provided. ## 3. Architecture ### 3.1 Layered model ``` ┌─────────────────────────────────────────────┐ │ Templates + REST API + Download Redirect │ │ (consume the interface, no provider logic) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ Provider factory: │ │ projects_portfolio_get_provider($post_id)│ │ reads post meta, returns adapter │ └─────────────────────────────────────────────┘ │ ┌────────────┴────────────┐ ▼ ▼ GitHub_Provider Gitea_Provider (implements (implements Repository_Provider) Repository_Provider) │ │ └────────────┬────────────┘ ▼ ┌─────────────────────────────────────────────┐ │ WP HTTP API + transient cache │ └─────────────────────────────────────────────┘ ``` ### 3.2 File layout ``` projects-portfolio/ ├── projects-portfolio.php Plugin bootstrap; CPT/taxonomy wiring; rewrite │ rules; download redirect; REST registration; │ admin columns; social-share buttons ├── composer.json / composer.lock Dev-only deps (PHPUnit, Brain\Monkey, │ yoast/phpunit-polyfills) ├── phpunit.xml.dist PHPUnit configuration ├── .phpcs.xml PHPCS coding-style ruleset ├── README.md End-user documentation ├── DESIGN.md This file │ ├── admin/ │ ├── cpt-taxonomy.php `projects` CPT + `project-type` taxonomy │ ├── metabox.php Repository Provider / URL / Gitea base metabox │ ├── admin-settings.php Settings page (General + Gitea + Templates + Archives) │ └── rest-api.php REST route registration (if separated) │ ├── includes/ │ ├── helper-functions.php Provider-aware wrappers, settings aggregator │ ├── plugin-update-checker/ Bundled PUC v5.7 (self-update notifications) │ └── providers/ │ ├── interface-repository-provider.php Contract (7 methods) │ ├── class-github-provider.php GitHub adapter (preserves v1.0 behavior) │ ├── class-gitea-provider.php Gitea adapter (self-hosted, configurable) │ └── class-provider-factory.php projects_portfolio_get_provider() │ ├── templates/ │ ├── single-projects.php Per-project detail template │ ├── archive-projects.php CPT archive │ └── taxonomy-project-type.php Taxonomy archive │ ├── assets/ │ ├── css/style.css Front-end styles │ ├── css/admin-styles.css Admin styles │ ├── js/buttons.js (Legacy — retained for compat, unused) │ └── icons/ Lucide brand-share SVG icons (Facebook, X, │ LinkedIn, Mail, Reddit, WhatsApp, Pinterest) │ ├── languages/ Translation files (.pot/.po/.mo/.l10n.php) │ ├── scripts/ │ └── release-helper.py GHA workflow helper: build payload, │ extract id, extract upload_url │ ├── tests/ │ ├── bootstrap.php Brain\Monkey bootstrap + WP function stubs │ ├── wp-stubs.php Shared WP function stubs (brain\monkey safe) │ ├── test-github-provider.php │ ├── test-gitea-provider.php │ ├── test-provider-factory.php │ ├── test-metabox.php │ └── test-settings.php │ ├── .github/workflows/ │ └── release.yml Builds projects-portfolio-v.zip on `v*` │ tag push; branches on host (Gitea Actions vs │ GitHub Actions). │ ├── specs/ Design specs (Gitea-support, release-workflow) ├── plans/ Implementation plans └── docs/superpowers/ Skill workflow artifacts ``` ### 3.3 Provider interface ```php interface Repository_Provider { public function get_id(): string; // 'github' | 'gitea' public function get_label(): string; // 'GitHub' | 'Gitea' public function get_repo_data(): ?array; // normalized shape (see §6) public function get_release_url(): ?string; // zip URL or null public function get_latest_version(): string; // tag name or 'Unknown' public function get_repo_browse_url(): string; // public repo URL public function get_owner_data( string $owner_login ): ?array; // avatar/login/html_url } ``` Both adapters implement this contract. Templates, the REST endpoint, and the download handler never branch on provider — they only call the interface. ## 4. Per-project storage Post meta keys (all `string` unless noted): | Key | Type | Notes | |---|---|---| | `_projects_portfolio_provider` | `'github'` \| `'gitea'` | Defaults to `'github'` when missing. | | `_projects_portfolio_repo_url` | string (URL) | Canonical repo URL. Falls back to legacy `_projects_portfolio_github_url` when empty. | | `_projects_portfolio_gitea_base_url` | string (URL) | Per-project Gitea override. Falls back to the global option when empty. | | `_projects_portfolio_github_url` | string (URL) | **Deprecated, read-only.** Retained for backward compatibility. Cleared when the project is saved with provider = `gitea`, or when provider = `github` and the new `repo_url` is empty. | | `_projects_portfolio_download_count` | int | Incremented locally by the download endpoint. | ### 4.1 Lazy migration The metabox save handler performs a one-time lazy migration: ``` on save: if (provider meta empty AND repo_url empty AND legacy github_url non-empty): set provider = 'github' set repo_url = legacy github_url if (provider meta == 'gitea'): delete legacy github_url if (provider meta == 'github' AND new repo_url is empty): delete legacy github_url ``` Existing projects that were configured before the Gitea support was added keep working without any data-migration step. ## 5. Global settings Options (all registered via the WP Options API): | Option | Default | Purpose | |---|---|---| | `projects_portfolio_github_api_token` | `''` | Bearer token for authenticated GitHub requests. | | `projects_portfolio_gitea_api_token` | `''` | Bearer token for authenticated Gitea requests. | | `projects_portfolio_default_gitea_base_url` | `'https://codeberg.org'` | Used when a Gitea project has no per-project override. | | `projects_portfolio_share_telemetry` | `'0'` | Reserved for future telemetry. | | `projects_portfolio_templates_` | `'0'` | One per template field (version, last_updated, license, language, downloads, forks, stargazers_count, open_issues_count, github_owner). | | `projects_portfolio_archives_` | `'0'` | One per archive field (archive_title, project_title, project_excerpt, project_buttons). | `projects_portfolio_settings()` aggregates all of the above into a single array (cached in a function-local static for the request) and is consumed by templates. ## 6. Normalized repo data shape Both adapters return the same shape from `get_repo_data()`. The GitHub adapter returns it natively; the Gitea adapter maps its native fields. ```php [ 'owner' => [ 'avatar_url' => string, 'login' => string, 'html_url' => string, ], 'updated_at' => string, // ISO 8601 'language' => string, 'license' => [ 'name' => string ], // 'None' if unset 'stargazers_count' => int, // mapped from `stars_count` for Gitea 'forks_count' => int, 'open_issues_count' => int, ] ``` ### 6.1 Gitea → normalized mapping | Gitea field | Normalized field | |---|---| | `stars_count` | `stargazers_count` | | `forks_count` | `forks_count` | | `open_issues_count` | `open_issues_count` | | `updated_at` | `updated_at` | | `language` | `language` | | `license` (string \| null \| object) | `license.name` ('None' if unset) | | `owner.avatar_url` | `owner.avatar_url` | | `owner.login` | `owner.login` | | `owner.html_url` | `owner.html_url` (falls back to `{base}/{owner}`) | ## 7. Endpoints ### 7.1 GitHub | Purpose | URL | |---|---| | Repo metadata | `https://api.github.com/repos/{owner}/{repo}` | | Latest release | `…/{owner}/{repo}/releases/latest` | | Owner profile | `https://api.github.com/users/{login}` | ### 7.2 Gitea | Purpose | URL | |---|---| | Repo metadata | `{base}/api/v1/repos/{owner}/{repo}` | | Latest release | `{base}/api/v1/repos/{owner}/{repo}/releases/latest` (returns a single object) | | Owner profile | `{base}/api/v1/users/{login}` | | Archive fallback | `{base}/{owner}/{repo}/archive/refs/tags/{tag}.zip` | The Gitea release endpoint returns a **single release object**, not a list (unlike GitHub's). The adapter detects shape with `isset($body['tag_name'])` and treats it as a release object directly. ### 7.3 Authentication - GitHub: header `Authorization: token `. - Gitea: header `Authorization: token `. The PAT is stored in `projects_portfolio_gitea_api_token` and supplied via `GITEA_TOKEN` repo secret on the release runner. ### 7.4 Plugin-side endpoints | Route | Purpose | |---|---| | `/download/{id}/` | Custom rewrite rule. Resolves to `index.php?project_download_id=`. Handler at `template_redirect`: looks up the project's release URL via the provider, increments `_projects_portfolio_download_count`, then `wp_redirect()` to the upstream URL. Uses `wp_redirect()` (not `wp_safe_redirect()`) because the destination is a user-configured external host — `wp_safe_redirect()` would reject it and fall back to `admin_url()`. | | `/wp-json/projects/v1/projects` | Lists all projects with their normalized metadata and download URL. Backward-compatible field names (`github_url`, `github_data`) plus a sibling `provider` field. | ## 8. Provider interface — method semantics | Method | GitHub | Gitea | |---|---|---| | `get_id()` | `'github'` | `'gitea'` | | `get_label()` | `'GitHub'` | `'Gitea'` | | `get_repo_data()` | Returns the GitHub repo object directly (already matches the normalized shape). | Maps `stars_count → stargazers_count`, normalizes `license` to `{name: string}`, returns null on non-200. | | `get_release_url()` | Iterates `assets[]` for a `.zip` entry; falls back to `zipball_url`. | Iterates the single release object's `assets[]`; falls back to `{base}/{owner}/{repo}/archive/refs/tags/{tag}.zip`. | | `get_latest_version()` | Returns `tag_name`. | Same. | | `get_repo_browse_url()` | Returns the user-supplied URL. | Returns `{base}/{owner_repo_path}`. | | `get_owner_data($login)` | GET `/users/{login}` via `file_get_contents` (legacy). | GET `{base}/api/v1/users/{login}` via `wp_remote_get`. | ## 9. Template layer The template engine uses the standard WordPress template-hierarchy lookup. The plugin registers `single-projects.php`, `archive-projects.php`, and `taxonomy-project-type.php` in its own `templates/` directory. Theme authors may override by copying any of these into their theme root. The single-projects template: 1. Calls `projects_portfolio_get_repo_data($post_id)` once and reuses the result for the whole page. 2. Renders the buttons (Download, View Repo), the metadata table, the project-owner block, and the social-share action via `do_action('projects_after_download_button', $post_id)`. 3. Uses `background-image` (not ``) for the owner avatar, with explicit pixel sizing (`width: 40px; height: 40px; background-size: 40px 40px`) because on some hosting environments the `cover` keyword for `background-size` is silently dropped and falls back to `auto auto` — which produces a tiny top-left-anchored rendering instead of a centered cover crop. Pixel values avoid that quirk. ## 10. Release workflow `.github/workflows/release.yml` triggers on `v*` tag push. On `ubuntu-latest` it: 1. Checks out the repo at the tag's commit. 2. Builds the plugin zip via an embedded Python script (`zipfile` stdlib — `zip` CLI is not guaranteed on Gitea Actions runners). 3. Uploads as workflow artifact. 4. Creates or updates a release via the host's REST API: - GitHub.com path: POST `https://api.github.com/repos/{owner}/{repo}/releases` using `secrets.GITHUB_TOKEN`, then upload asset via the per-release `upload_url` template. - Gitea path: POST `{api}/v1/repos/{owner}/{repo}/releases` using `secrets.GITEA_TOKEN`, then POST the asset to `{api}/v1/repos/{owner}/{repo}/releases/{id}/assets`. Both paths first **look up an existing release by tag** (idempotent on re-run), reusing its id if present. The asset upload uses `|| echo "::warning::Asset upload failed (asset may already exist); continuing."` so duplicate-asset 409s don't fail the run. ## 11. Plugin update checker (self-update) The plugin ships a bundled copy of [YahnisElsts/plugin-update-checker](https://github.com/YahnisElsts/plugin-update-checker) v5.7 (latest upstream as of May 2026) so the plugin can advertise in-plugin update notifications when newer tags are pushed to the repo. Because the repo URL is a Gitea instance (not github.com), PUC falls back to its plain-JSON metadata mode and the plugin skips calling `setBranch()` — that method only exists on the VCS-specific adapter (`YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker` via the `VcsCheckerMethods` trait), and calling it on the plain metadata adapter throws `Error: Call to undefined method ...`. ## 12. Testing PHPUnit under `tests/`. The bootstrap loads Brain\Monkey and stubs the small set of WP functions the providers call (`wp_remote_get`, `wp_remote_retrieve_response_code`, `wp_remote_retrieve_body`, `wp_remote_retrieve_header`, `is_wp_error`, `set_transient`, `get_transient`, `update_post_meta`, `get_post_meta`, `delete_post_meta`, `wp_unslash`, `esc_url_raw`, `error_log`). Coverage: - `test-github-provider.php`: URL construction, headers, `.zip` asset selection, `zipball_url` fallback, owner profile fetch. - `test-gitea-provider.php`: URL construction, header behavior, field mapping (`stars_count → stargazers_count`, license normalization for string/null/object), `.zip` asset selection, archive fallback, **release-object shape (single object, not array)**, empty array handling, self-hosted base URL. - `test-provider-factory.php`: provider selection from meta, legacy URL fallback, per-project Gitea base override. - `test-metabox.php`: save persistence, lazy migration, provider-switch clearing of legacy URL, empty-repo clearing of legacy URL. - `test-settings.php`: new option keys exposed via `projects_portfolio_settings()`. ## 13. i18n Text domain `projects-wp`. Translation template at `languages/projects-wp.pot`; sibling `.po`/`.mo`/`.l10n.php` files for `es_MX` and `fr_FR`. All user-facing strings run through `esc_html_e()` / `esc_html__()` / `esc_attr_e()` / `esc_attr__()`. ## 14. Backward compatibility - Legacy post meta key `_projects_portfolio_github_url` is read with a lazy fallback in the factory and the single-projects template. - Legacy GitHub-only helper functions (`projects_portfolio_get_github_data`, `projects_portfolio_get_github_release_url`, `projects_portfolio_get_version_from_github`, `projects_portfolio_github_owner`) are retained verbatim with `_deprecated_function()` notices. New code uses the provider-aware wrappers. - REST response shape retains the `github_url` and `github_data` field names (legacy consumers); adds a sibling `provider` field. New consumers should branch on `provider`. ## 15. Open follow-ups - **In-tree version constant stays at the version that was first set for the Gitea-support release** (1.1.1) and has not been bumped with subsequent tags (1.1.2 → 1.1.14). The release workflow builds the zip from the tag name, but the plugin header reports the in-tree constant. Future releases should bump both at the same time. Tracked as a release-process gap, not a bug. - **CSS cover keyword quirk** is currently worked around by using pixel values; future improvement is to investigate the host where `cover` falls back to `auto auto` and report upstream. - **The bundled Lucide icons are a curated subset** (7 social networks). If a new network is added to the share buttons, drop a new `.svg` into `assets/icons/` and add a `$svg_icon()` call in `projects_portfolio.php`.