Comprehensive design document covering the plugin's goals, layered architecture, file layout, provider interface contract, normalized data shape, per-project and global storage, endpoint catalog, template layer, release workflow, self-update checker, testing, i18n, backward compatibility, and known follow-ups.
19 KiB
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
projectscustom post type andproject-typetaxonomy — 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<version>.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
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_<key> |
'0' |
One per template field (version, last_updated, license, language, downloads, forks, stargazers_count, open_issues_count, github_owner). |
projects_portfolio_archives_<key> |
'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.
[
'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 <PAT>. - Gitea: header
Authorization: token <PAT>. The PAT is stored inprojects_portfolio_gitea_api_tokenand supplied viaGITEA_TOKENrepo secret on the release runner.
7.4 Plugin-side endpoints
| Route | Purpose |
|---|---|
/download/{id}/ |
Custom rewrite rule. Resolves to index.php?project_download_id=<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:
- Calls
projects_portfolio_get_repo_data($post_id)once and reuses the result for the whole page. - 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). - Uses
background-image(not<img>) for the owner avatar, with explicit pixel sizing (width: 40px; height: 40px; background-size: 40px 40px) because on some hosting environments thecoverkeyword forbackground-sizeis silently dropped and falls back toauto 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:
- Checks out the repo at the tag's commit.
- Builds the plugin zip via an embedded Python script (
zipfilestdlib —zipCLI is not guaranteed on Gitea Actions runners). - Uploads as workflow artifact.
- Creates or updates a release via the host's REST API:
- GitHub.com path: POST
https://api.github.com/repos/{owner}/{repo}/releasesusingsecrets.GITHUB_TOKEN, then upload asset via the per-releaseupload_urltemplate. - Gitea path: POST
{api}/v1/repos/{owner}/{repo}/releasesusingsecrets.GITEA_TOKEN, then POST the asset to{api}/v1/repos/{owner}/{repo}/releases/{id}/assets.
- GitHub.com path: POST
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 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,.zipasset selection,zipball_urlfallback, owner profile fetch.test-gitea-provider.php: URL construction, header behavior, field mapping (stars_count → stargazers_count, license normalization for string/null/object),.zipasset 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 viaprojects_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_urlis 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_urlandgithub_datafield names (legacy consumers); adds a siblingproviderfield. New consumers should branch onprovider.
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
coverfalls back toauto autoand 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
<name>.svgintoassets/icons/and add a$svg_icon()call inprojects_portfolio.php.