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.
This commit is contained in:
Keith Solomon
2026-08-16 09:18:18 -05:00
parent 9b6aa8a6e8
commit 4d86db868c
+314
View File
@@ -0,0 +1,314 @@
# 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
```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_<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.
```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 <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](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 `<name>.svg` into `assets/icons/` and add a `$svg_icon()` call in `projects_portfolio.php`.