From 4d86db868c6f7024273281609a3a1179a8b28b78 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 16 Aug 2026 09:18:18 -0500 Subject: [PATCH] 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. --- DESIGN.md | 314 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..06225ed --- /dev/null +++ b/DESIGN.md @@ -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.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`. \ No newline at end of file