Files
Projects-Portfolio/DESIGN.md
T
Keith Solomon 4d86db868c Add DESIGN.md
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.
2026-08-16 09:18:18 -05:00

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 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<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 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=<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 <img>) 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 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 <name>.svg into assets/icons/ and add a $svg_icon() call in projects_portfolio.php.