3 Commits
Author SHA1 Message Date
Keith Solomon 58a0b0c808 Add website fields to project meta box
Adds a 'This project is a website' toggle to the Repository meta box.
When checked, site fields (URL, platform, optional screenshot) replace the
git-repo fields; when unchecked, repo fields are shown and site fields
are cleared. Existing posts default to repo mode.

- admin/metabox.php: checkbox, site URL/platform/screenshot inputs with
  media-library picker, JS toggle, save handler that switches modes
- includes/helper-functions.php: projects_portfolio_is_website / get
  _site_url / get_site_platform / get_site_screenshot_url helpers
- templates/single-projects.php: website-mode rendering (Visit Site
  button, platform row, screenshot)
- tests/test-metabox.php: website-mode persistence + mode-switch tests
- tests/wp-stubs.php: sanitize_text_field, absint stubs
2026-08-22 15:16:51 -05:00
Keith Solomon dc11cb6a72 🔵 other: Add phpcs lint rules 2026-08-16 09:47:54 -05:00
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
7 changed files with 745 additions and 39 deletions
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0"?>
<ruleset name="Coding Style Checks">
<description>Coding Style Checks.</description>
<ini name="error_reporting" value="E_ALL &#38; ~E_DEPRECATED" />
<arg value="sp"/>
<arg name="colors"/>
<arg name="extensions" value="php,html"/>
<arg name="parallel" value="2048"/>
<exclude-pattern>vendor/</exclude-pattern>
<exclude-pattern>node_modules/</exclude-pattern>
<rule ref="WordPress">
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
<exclude name="Generic.Functions.CallTimePassByReference"/>
<exclude name="Universal.WhiteSpace.PrecisionAlignment.Found"/>
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralText"/>
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralDomain"/>
<exclude name="WordPress.WP.I18n.NoEmptyStrings"/>
<exclude name="WordPress.WP.EnqueuedResourceParameters.MissingVersion"/>
<exclude name="WordPress.PHP.YodaConditions.NotYoda"/>
<exclude name="WordPress.PHP.NoSilencedErrors.Discouraged"/>
<exclude name="WordPress.Files.FileName.NotHyphenatedLowercase"/>
<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
<exclude name="WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase"/>
<exclude name="WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase"/>
<exclude name="WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase"/>
<exclude name="WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase"/>
<exclude name="WordPress.NamingConventions.ValidHookName.NotLowercase"/>
<exclude name="WordPress.NamingConventions.ValidHookName.UseUnderscores"/>
<exclude name="WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid"/>
<exclude name="WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid"/>
<exclude name="WordPress.DB.SlowDBQuery.slow_db_query_tax_query"/>
<exclude name="WordPress.DB.SlowDBQuery.slow_db_query_meta_query"/>
<exclude name="WordPress.Security.NonceVerification.Recommended"/>
<exclude name="Squiz.Commenting.FileComment.MissingPackageTag"/>
<exclude name="Squiz.Commenting.FileComment.Missing"/>
<exclude name="Squiz.Commenting.FileComment.WrongStyle"/>
<exclude name="Squiz.Commenting.InlineComment.InvalidEndChar"/>
</rule>
<rule ref="Internal.NoCodeFound">
<severity>0</severity>
</rule>
</ruleset>
+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`.
+151 -4
View File
@@ -33,6 +33,7 @@ add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' );
function projects_portfolio_render_meta_box( $post ) {
wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' );
$is_website = (bool) get_post_meta( $post->ID, '_projects_portfolio_is_website', true );
$provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
if ( '' === $provider ) {
$provider = 'github';
@@ -42,7 +43,19 @@ function projects_portfolio_render_meta_box( $post ) {
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
}
$gitea_base_url = get_post_meta( $post->ID, '_projects_portfolio_gitea_base_url', true );
$site_url = get_post_meta( $post->ID, '_projects_portfolio_site_url', true );
$site_platform = get_post_meta( $post->ID, '_projects_portfolio_site_platform', true );
$site_screenshot = (int) get_post_meta( $post->ID, '_projects_portfolio_site_screenshot_id', true );
?>
<p>
<label>
<input type="checkbox" id="projects_portfolio_is_website" name="projects_portfolio_is_website" value="1" <?php checked( $is_website, true ); ?> />
<?php esc_html_e( 'This project is a website', 'projects-wp' ); ?>
</label>
</p>
<div class="projects-portfolio-repo-fields" <?php echo $is_website ? 'style="display:none;"' : ''; ?>>
<p>
<label for="projects_portfolio_provider"><?php esc_html_e( 'Provider', 'projects-wp' ); ?></label>
<select id="projects_portfolio_provider" name="projects_portfolio_provider" style="width: 100%;">
@@ -59,22 +72,110 @@ function projects_portfolio_render_meta_box( $post ) {
<input type="url" id="projects_portfolio_gitea_base_url" name="projects_portfolio_gitea_base_url" value="<?php echo esc_attr( $gitea_base_url ); ?>" style="width: 100%;" placeholder="https://codeberg.org" />
<span class="description"><?php esc_html_e( 'Leave blank to use the default from Settings.', 'projects-wp' ); ?></span>
</p>
</div>
<div class="projects-portfolio-website-fields"<?php echo $is_website ? '' : ' style="display:none;"'; ?>>
<p>
<label for="projects_portfolio_site_url"><?php esc_html_e( 'Site URL', 'projects-wp' ); ?></label>
<input type="url" id="projects_portfolio_site_url" name="projects_portfolio_site_url" value="<?php echo esc_attr( $site_url ); ?>" style="width: 100%;" placeholder="https://example.com" />
</p>
<p>
<label for="projects_portfolio_site_platform"><?php esc_html_e( 'Platform', 'projects-wp' ); ?></label>
<textarea id="projects_portfolio_site_platform" name="projects_portfolio_site_platform" rows="3" style="width: 100%;" placeholder="<?php esc_attr_e( 'e.g. WordPress, custom Next.js, etc.', 'projects-wp' ); ?>"><?php echo esc_textarea( $site_platform ); ?></textarea>
</p>
<p>
<label for="projects_portfolio_site_screenshot_id"><?php esc_html_e( 'Screenshot (optional)', 'projects-wp' ); ?></label>
<span class="projects-portfolio-screenshot-preview" style="<?php echo $site_screenshot ? '' : 'display:none;'; ?> margin-right: 8px;">
<?php if ( $site_screenshot ) : ?>
<?php echo wp_get_attachment_image( $site_screenshot, 'thumbnail' ); // phpcs:ignore ?>
<?php endif; ?>
</span>
<input type="hidden" id="projects_portfolio_site_screenshot_id" name="projects_portfolio_site_screenshot_id" value="<?php echo esc_attr( $site_screenshot ); ?>" />
<button type="button" class="button projects-portfolio-screenshot-select"><?php esc_html_e( 'Select / Upload', 'projects-wp' ); ?></button>
<button type="button" class="button projects-portfolio-screenshot-clear" <?php echo $site_screenshot ? '' : 'style="display:none;"'; ?>><?php esc_html_e( 'Remove', 'projects-wp' ); ?></button>
</p>
</div>
<script>
(function(){
var sel = document.getElementById('projects_portfolio_provider');
if (!sel) return;
if (sel) {
var gitea = document.querySelector('.projects-portfolio-gitea-only');
function toggle(){
function toggleGitea(){
if (!gitea) return;
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
}
sel.addEventListener('change', toggle);
toggle();
sel.addEventListener('change', toggleGitea);
toggleGitea();
}
var toggle = document.getElementById('projects_portfolio_is_website');
if (toggle) {
var repoBox = document.querySelector('.projects-portfolio-repo-fields');
var siteBox = document.querySelector('.projects-portfolio-website-fields');
function toggleMode(){
var on = !!toggle.checked;
if (repoBox) repoBox.style.display = on ? 'none' : '';
if (siteBox) siteBox.style.display = on ? '' : 'none';
}
toggle.addEventListener('change', toggleMode);
toggleMode();
}
var frame;
var selectBtn = document.querySelector('.projects-portfolio-screenshot-select');
var clearBtn = document.querySelector('.projects-portfolio-screenshot-clear');
var inputEl = document.getElementById('projects_portfolio_site_screenshot_id');
var previewEl = document.querySelector('.projects-portfolio-screenshot-preview');
function openFrame(){
if (typeof wp === 'undefined' || !wp.media) { return; }
frame = wp.media({
title: '<?php echo esc_js( __( 'Select Screenshot', 'projects-wp' ) ); ?>',
button: { text: '<?php echo esc_js( __( 'Use this image', 'projects-wp' ) ); ?>' },
library: { type: 'image' },
multiple: false
});
frame.on('select', function(){
var attachment = frame.state().get('selection').first().toJSON();
inputEl.value = attachment.id;
previewEl.innerHTML = '<img src="' + attachment.sizes && attachment.sizes.thumbnail ? attachment.sizes.thumbnail.url : attachment.url + '" alt="" />';
previewEl.style.display = '';
clearBtn.style.display = '';
});
frame.open();
}
if (selectBtn) { selectBtn.addEventListener('click', openFrame); }
if (clearBtn) {
clearBtn.addEventListener('click', function(){
inputEl.value = '';
previewEl.innerHTML = '';
previewEl.style.display = 'none';
clearBtn.style.display = 'none';
});
}
})();
</script>
<?php
}
/**
* Enqueue media picker assets for the project meta box.
*
* @since 1.4.0
* @return void
*/
function projects_portfolio_enqueue_media_for_meta_box( $hook ) {
if ( ! in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || 'projects' !== $screen->post_type ) {
return;
}
wp_enqueue_media();
}
add_action( 'admin_enqueue_scripts', 'projects_portfolio_enqueue_media_for_meta_box' );
/**
* Save the Repository meta box.
*
@@ -103,6 +204,14 @@ function projects_portfolio_save_meta_box( $post_id ) {
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
}
// "This project is a website" toggle — drives which set of fields is kept.
$is_website = isset( $_POST['projects_portfolio_is_website'] ) && '1' === $_POST['projects_portfolio_is_website'];
if ( $is_website ) {
update_post_meta( $post_id, '_projects_portfolio_is_website', '1' );
} else {
delete_post_meta( $post_id, '_projects_portfolio_is_website' );
}
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
@@ -130,5 +239,43 @@ function projects_portfolio_save_meta_box( $post_id ) {
if ( isset( $_POST['projects_portfolio_gitea_base_url'] ) ) {
update_post_meta( $post_id, '_projects_portfolio_gitea_base_url', esc_url_raw( wp_unslash( $_POST['projects_portfolio_gitea_base_url'] ) ) );
}
// Website fields.
if ( isset( $_POST['projects_portfolio_site_url'] ) ) {
$site_url = esc_url_raw( wp_unslash( $_POST['projects_portfolio_site_url'] ) );
if ( '' === $site_url ) {
delete_post_meta( $post_id, '_projects_portfolio_site_url' );
} else {
update_post_meta( $post_id, '_projects_portfolio_site_url', $site_url );
}
}
if ( isset( $_POST['projects_portfolio_site_platform'] ) ) {
$platform = sanitize_text_field( wp_unslash( $_POST['projects_portfolio_site_platform'] ) );
if ( '' === $platform ) {
delete_post_meta( $post_id, '_projects_portfolio_site_platform' );
} else {
update_post_meta( $post_id, '_projects_portfolio_site_platform', $platform );
}
}
if ( isset( $_POST['projects_portfolio_site_screenshot_id'] ) ) {
$screenshot_id = absint( wp_unslash( $_POST['projects_portfolio_site_screenshot_id'] ) );
if ( $screenshot_id <= 0 ) {
delete_post_meta( $post_id, '_projects_portfolio_site_screenshot_id' );
} else {
update_post_meta( $post_id, '_projects_portfolio_site_screenshot_id', $screenshot_id );
}
}
// When modes switch, clear the opposite mode's fields so the project is cleanly one or the other.
if ( $is_website ) {
delete_post_meta( $post_id, '_projects_portfolio_provider' );
delete_post_meta( $post_id, '_projects_portfolio_repo_url' );
delete_post_meta( $post_id, '_projects_portfolio_gitea_base_url' );
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
} else {
delete_post_meta( $post_id, '_projects_portfolio_site_url' );
delete_post_meta( $post_id, '_projects_portfolio_site_platform' );
delete_post_meta( $post_id, '_projects_portfolio_site_screenshot_id' );
}
}
add_action( 'save_post', 'projects_portfolio_save_meta_box' );
+50
View File
@@ -104,6 +104,56 @@ function projects_portfolio_get_repo_browse_url( int $post_id ): string {
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
}
/**
* Whether this project is configured as a website rather than a git repo.
*
* @since 1.4.0
* @param int $post_id
* @return bool
*/
function projects_portfolio_is_website( int $post_id ): bool {
return (bool) get_post_meta( $post_id, '_projects_portfolio_is_website', true );
}
/**
* Public URL for the project's website (when configured as a website).
*
* @since 1.4.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_site_url( int $post_id ): string {
return (string) get_post_meta( $post_id, '_projects_portfolio_site_url', true );
}
/**
* Platform label for the project's website (when configured as a website).
*
* @since 1.4.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_site_platform( int $post_id ): string {
return (string) get_post_meta( $post_id, '_projects_portfolio_site_platform', true );
}
/**
* Screenshot attachment URL for the project's website.
*
* @since 1.4.0
* @param int $post_id
* @param string $size
* @return string
*/
function projects_portfolio_get_site_screenshot_url( int $post_id, string $size = 'large' ): string {
$attachment_id = (int) get_post_meta( $post_id, '_projects_portfolio_site_screenshot_id', true );
if ( $attachment_id <= 0 ) {
return '';
}
$src = wp_get_attachment_image_src( $attachment_id, $size );
return $src[0] ?? '';
}
/**
* GitHub API call for owner data
*
+34 -2
View File
@@ -13,6 +13,13 @@ $settings = projects_portfolio_settings();
$project_id = get_the_ID();
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
$is_website = projects_portfolio_is_website( $project_id );
if ( $is_website ) {
$site_url = projects_portfolio_get_site_url( $project_id );
$site_platform = projects_portfolio_get_site_platform( $project_id );
$site_shot_url = projects_portfolio_get_site_screenshot_url( $project_id, 'large' );
} else {
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
@@ -29,6 +36,7 @@ $last_updated = $repo_data['updated_at'] ?? '';
$language = $repo_data['language'] ?? '';
$license = $repo_data['license']['name'] ?? 'None';
$browse_url = $repo_url ? projects_portfolio_get_repo_browse_url( $project_id ) : '';
}
if ( $last_updated ) {
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
@@ -49,6 +57,13 @@ if ( $last_updated ) {
</div>
<div class="project-main-content">
<?php if ( ! empty( $site_shot_url ) ) : ?>
<div class="project-screenshot">
<a href="<?php echo esc_url( $site_url ); ?>" target="_blank" rel="noopener noreferrer">
<img src="<?php echo esc_url( $site_shot_url ); ?>" alt="<?php echo esc_attr( get_the_title() ); ?>" />
</a>
</div>
<?php endif; ?>
<div class="project-description">
<h1 class="project-title">
<?php
@@ -68,7 +83,15 @@ if ( $last_updated ) {
<a href="<?php echo esc_url( site_url( '/download/' . $project_id ) ); ?>" class="button project-download-button">
<?php esc_html_e( 'Download Now', 'projects-wp' ); ?>
</a>
<?php if ( $repo_url ) : ?>
<?php if ( $is_website ) : ?>
<?php if ( ! empty( $site_url ) ) : ?>
<a href="<?php echo esc_url( $site_url ); ?>" class="button project-repo-button" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'Visit Site', 'projects-wp' ); ?>
</a>
<?php else : ?>
<p><?php esc_html_e( 'Website:', 'projects-wp' ); ?> <?php esc_html_e( 'No URL linked.', 'projects-wp' ); ?></p>
<?php endif; ?>
<?php elseif ( ! empty( $repo_url ) ) : ?>
<a href="<?php echo esc_url( $browse_url ); ?>" class="button project-repo-button" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View Repo', 'projects-wp' ); ?>
</a>
@@ -84,6 +107,14 @@ if ( $last_updated ) {
?>
<table class="project-meta-table">
<tbody>
<?php if ( $is_website ) : ?>
<?php if ( $site_platform ) : ?>
<tr>
<th><?php esc_html_e( 'Platform:', 'projects-wp' ); ?></th>
<td><?php echo nl2br( esc_html( $site_platform ) ); // phpcs:ignore ?></td>
</tr>
<?php endif; ?>
<?php else : ?>
<?php if ( $settings['templates']['version'] ) : ?>
<tr>
<th><?php esc_html_e( 'Version:', 'projects-wp' ); ?></th>
@@ -141,12 +172,13 @@ if ( $last_updated ) {
<?php
endif;
endif;
endif; // end repo-mode meta rows (else branch of $is_website).
?>
</tbody>
</table>
</div>
<?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
<?php if ( ! $is_website && $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
<div class="project-owner">
<span class="project-owner-avatar" style="display:block !important;width:50px !important;height:50px !important;min-width:50px !important;min-height:50px !important;max-width:50px !important;max-height:50px !important;border-radius:50% !important;background-image:url('<?php echo esc_url( $owner_avatar ); ?>') !important;background-size:40px 40px !important;background-position:center center !important;background-repeat:no-repeat !important;flex-shrink:0 !important;flex-grow:0 !important;" aria-label="<?php echo esc_attr( $owner_name ); ?>" role="img"></span>
<span class="project-owner-name">
+111
View File
@@ -142,4 +142,115 @@ class Metabox_Test extends \PHPUnit\Framework\TestCase {
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
public function test_website_mode_persists_website_fields_and_clears_repo_fields(): void {
$existing = [
'_projects_portfolio_provider' => 'github',
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
];
$deleted = [];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
unset( $existing[ $key ] );
$deleted[] = $key;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_is_website'] = '1';
$_POST['projects_portfolio_site_url'] = 'https://example.com';
$_POST['projects_portfolio_site_platform'] = 'WordPress';
$_POST['projects_portfolio_site_screenshot_id'] = '42';
projects_portfolio_save_meta_box( 11 );
$this->assertSame( '1', $existing['_projects_portfolio_is_website'] );
$this->assertSame( 'https://example.com', $existing['_projects_portfolio_site_url'] );
$this->assertSame( 'WordPress', $existing['_projects_portfolio_site_platform'] );
$this->assertSame( 42, $existing['_projects_portfolio_site_screenshot_id'] );
// Repo-mode fields should be cleared when switching to website mode.
$this->assertArrayNotHasKey( '_projects_portfolio_provider', $existing );
$this->assertArrayNotHasKey( '_projects_portfolio_repo_url', $existing );
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_provider', $deleted );
$this->assertContains( '_projects_portfolio_repo_url', $deleted );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
public function test_unchecked_website_checkbox_clears_website_fields(): void {
$existing = [
'_projects_portfolio_is_website' => '1',
'_projects_portfolio_site_url' => 'https://example.com',
'_projects_portfolio_site_platform' => 'WordPress',
'_projects_portfolio_site_screenshot_id' => 42,
];
$deleted = [];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
unset( $existing[ $key ] );
$deleted[] = $key;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
// is_website is absent — repo mode is implied.
projects_portfolio_save_meta_box( 13 );
$this->assertArrayNotHasKey( '_projects_portfolio_is_website', $existing );
$this->assertArrayNotHasKey( '_projects_portfolio_site_url', $existing );
$this->assertArrayNotHasKey( '_projects_portfolio_site_platform', $existing );
$this->assertArrayNotHasKey( '_projects_portfolio_site_screenshot_id', $existing );
$this->assertContains( '_projects_portfolio_is_website', $deleted );
}
public function test_existing_repo_post_without_checkbox_keeps_repo_fields(): void {
$existing = [
'_projects_portfolio_provider' => 'github',
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'delete_post_meta' => function () { return true; },
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
// is_website not present in $_POST — should be treated as unchecked.
projects_portfolio_save_meta_box( 17 );
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
$this->assertSame( 'https://github.com/owner/repo', $existing['_projects_portfolio_repo_url'] );
$this->assertArrayNotHasKey( '_projects_portfolio_is_website', $existing );
}
}
+6
View File
@@ -80,6 +80,12 @@ if ( ! function_exists( 'wp_unslash' ) ) {
if ( ! function_exists( 'esc_url_raw' ) ) {
function esc_url_raw( $url ) { return $url; }
}
if ( ! function_exists( 'sanitize_text_field' ) ) {
function sanitize_text_field( $str ) { return is_string( $str ) ? trim( $str ) : ''; }
}
if ( ! function_exists( 'absint' ) ) {
function absint( $maybeint ) { return abs( (int) $maybeint ); }
}
// Minimal WP_Error stub if Brain\Monkey doesn't supply one.
if ( ! class_exists( 'WP_Error' ) ) {