17 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
Keith Solomon 9b6aa8a6e8 🔵 other: Bump version 2026-08-12 19:20:32 -05:00
Keith Solomon 9249a1e0a1 Refactor social sharing buttons and add Twitter SVG icon 2026-08-12 18:14:36 -05:00
Keith Solomon b7dfda1f99 Switch social-share icons to Lucide v0.460.0; shrink avatar to 40x40
Lucide (https://lucide.dev) replaces the hand-rolled Tabler paths. Lucide
is more compact and consistent, and ships under the ISC license (more
permissive than Tabler's MIT and equally fine for plugin redistribution).

Lucide has no brand icons for Reddit/WhatsApp/Pinterest, so we use
generic substitutes that preserve the visual intent:
- reddit.svg   <- lucide 'share-2'  (community share metaphor)
- whatsapp.svg <- lucide 'message-circle'
- pinterest.svg<- lucide 'pin'

Avatar: shrunk to 40x40 per user testing — sizes above 41x41 were being
clipped inside the round border on this host.
2026-08-12 17:52:44 -05:00
Keith Solomon 93c81d0fa6 Use explicit background-size 50px 50px instead of 'cover'
The 'cover' keyword was producing fallback 'auto auto' rendering in some
environments (likely a kses/CSS shorthand quirk). Switching to explicit
pixel values matching the container size has the same visual effect
for the 270x270 source image and works reliably across all browsers.
2026-08-12 17:31:51 -05:00
Keith Solomon e9b55bb0e1 Force avatar dimensions with !important on inline styles 2026-08-12 17:17:43 -05:00
Keith Solomon 426d60b692 Fix avatar and social icons with bulletproof rendering
Root cause of social icons rendering as alt text: wp_kses() stripped
the 'data:' prefix from data: URIs in img.src, leaving a relative URL
that 404s. Switched to real .svg files under assets/icons/ referenced
via plugins_url() — kses passes img tags with relative URLs cleanly,
and the icons load as standard images.

Root cause of avatar still being cropped: layout-side issues with
inline-styled <img> in a flex layout. Switched to a <span> with
inline background-image, background-size: cover, background-position:
center — no <img> element, no object-fit reliance, no way to crop wrong.

Also removed the outer <a> wrapper around the .project-owner div
(block-level element inside inline <a> is invalid HTML and was likely
contributing to layout weirdness). The owner name is now an explicit
<a> inside its own span.

Reverted kses to the default wp_kses_post() since we're back to
standard HTML elements only — no SVG, no data URIs, no inline styles
that need a custom allowed list.
2026-08-12 17:04:15 -05:00
Keith Solomon 60db82e459 Render social-share icons as <img> SVG data URIs
Inline <svg> elements render with their default width/height in browsers
even when 'style' and width/height attributes are set (a known quirk
in mixed HTML5/SVG content with some CSS specificity scenarios). Switched
to <img src='data:image/svg+xml,...'> instead. This is universally well-
supported, sizes via standard <img> CSS rules (no namespace/parser edge
cases), and the inline 'width=20 height=20 style=width:20px;height:20px'
on <img> reliably applies.

Added 'img' and 'style' to the kses allowed list so wp_kses() preserves
the data: URI src and the inline styles.
2026-08-12 12:08:24 -05:00
Keith Solomon c10866bffd Inline icon SVG dimensions + style + cache-bust plugin version
- Add inline width/height attributes AND style='width:20px;height:20px;
  display:block' to each social-share SVG. Inline styles have higher
  specificity than any CSS rule, so the icons should now size correctly
  regardless of theme CSS or specificity quirks.
- Bump PROJECTS_PORTFOLIO_VERSION to 1.1.9 (and plugin header) so
  WordPress invalidates the cached stylesheet URL.
2026-08-12 11:40:06 -05:00
Keith Solomon c127a1d065 Fix icon and avatar rendering: remove SVG viewport rects + inline avatar styles
- Removed the <rect x='0' y='0' width='24' height='24' fill='none'> viewport
  markers from each social-share SVG. With 'fill=none' the rect was
  still visible if any inherited stroke wasn't suppressed (which turned
  out to be unreliable across themes/CSS specificity).
- Removed redundant width='24' height='24' attributes from each SVG so
  the CSS .project-social-sharing svg { width:20px; height:20px } can
  size them unambiguously.
- Inlined avatar <img> styles (width:50px; height:50px; border-radius:50%;
  object-fit:cover; flex-shrink:0; display:block) so the avatar is
  guaranteed to render as a 50x50 circle regardless of theme CSS.
- Switched .project-owner-avatar to inline-flex so the span shrinks to
  the image's natural size within the flex parent.
2026-08-12 10:44:35 -05:00
Keith Solomon cd5405fe62 Allow SVG through kses in social-share buttons
wp_kses_post() strips <svg> tags by default (the 'post' context doesn't
allow them), so the icons were being silently removed from the rendered
HTML. Extend the allowed list to include svg/path/rect/circle/line with
the attributes the Tabler-style icons actually use.
2026-08-12 09:05:58 -05:00
Keith Solomon 98fd2fba5a Fix View Repo outline, social-share icon stroke, and owner avatar sizing
- .project-github-button renamed to .project-repo-button (added 1px border
  for proper outline style; the old class is gone so the button was rendering
  as unstyled <a>).
- Suppress inherited stroke on the Tabler SVG <rect> viewport marker — without
  this it draws a square outline inside the round social-share button.
- Move owner avatar inline styles into CSS with object-fit: cover, explicit
  width/height, and border-radius: 50%; add flex shrink/grow rules for the
  owner row's three spans so the avatar isn't squashed by the flex layout.
2026-08-12 08:49:56 -05:00
Keith Solomon 5c843dc76f Wire social-share hook + make Follow button provider-aware
The social-share buttons function was hooked to
projects_after_download_button, but the template never fired that action —
the buttons never rendered. Added a do_action() call after the View Repo
button.

The Follow button hardcoded a GitHub URL and used the GitHub-specific
github-button class (which only acts on github.com). Replaced with a
plain styled link pointing at the owner's actual URL (works for any
provider). Removed the assets/js/buttons.js enqueue since nothing uses
the github-button class anymore.

Renamed the project-github-button CSS class on the View Repo link to
project-repo-button since it's provider-neutral now.
2026-08-11 20:18:06 -05:00
Keith Solomon 37832b72da Fix download redirect: use wp_redirect() for external URLs
wp_safe_redirect() rejects external hosts not in WP's allowed list and
silently falls back to admin_url() — which is why /download/{id}/ was
redirecting to /wp-admin/ on a clean install. Switch to wp_redirect()
since the URL is explicitly user-configured.
2026-08-11 19:09:04 -05:00
Keith Solomon aab39f66af Fix Gitea release fetch: /releases/latest returns a single object, not an array 2026-08-11 16:52:27 -05:00
20 changed files with 991 additions and 103 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`.
+13 -13
View File
@@ -1,13 +1,13 @@
# Projects Portfolio # Projects Portfolio
Create a beautifully structured, developer-first showcase directory for your WordPress® plugins, themes, and patterns - powered by [GitHub](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio). Create a beautifully structured, developer-first showcase directory for your WordPress plugins, themes, and patterns - powered by git.
## What It Does ## What It Does
**Projects Portfolio** adds a custom post type (`projects`) and taxonomy (`project-type`) to your WordPress® admin, allowing you to: **Projects Portfolio** adds a custom post type (`projects`) and taxonomy (`project-type`) to your WordPress admin, allowing you to:
- Add plugins, themes, or patterns as projects. - Add plugins, themes, or patterns as projects.
- Connect each project to a GitHub repository. - Connect each project to a git repository.
- Automatically fetch and display data like: - Automatically fetch and display data like:
- Latest version - Latest version
- Stars, forks, issues - Stars, forks, issues
@@ -27,14 +27,14 @@ Create a beautifully structured, developer-first showcase directory for your Wor
### From GitHub ### From GitHub
1. **Download the latest ZIP** from the [GitHub Releases page](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio/releases). 1. **Download the latest ZIP** from the [GitHub Releases page](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio/releases).
2. Go to your WordPress® Dashboard → Plugins → Add New → Upload Plugin. 2. Go to your WordPress Dashboard → Plugins → Add New → Upload Plugin.
3. Upload the ZIP and activate the plugin. 3. Upload the ZIP and activate the plugin.
### From [keithsolomon.net](https://keithsolomon.net) ### From [keithsolomon.net](https://keithsolomon.net)
1. Visit the official plugin page: [Projects Portfolio](https://keithsolomon.net/projects/projects-portfolio). 1. Visit the official plugin page: [Projects Portfolio](https://keithsolomon.net/projects/projects-portfolio).
2. Download the ZIP. 2. Download the ZIP.
3. Install it via the WordPress® dashboard like above. 3. Install it via the WordPress dashboard like above.
--- ---
@@ -42,7 +42,7 @@ Create a beautifully structured, developer-first showcase directory for your Wor
### Add a New Project ### Add a New Project
1. In the WordPress® admin, go to **Projects → Add New**. 1. In the WordPress admin, go to **Projects → Add New**.
2. Add your project title, description, and featured image. 2. Add your project title, description, and featured image.
3. Select a **Project Type**: Plugin, Theme, or Pattern. 3. Select a **Project Type**: Plugin, Theme, or Pattern.
4. In the sidebar, choose **Provider** (GitHub or Gitea) and paste the **Repository URL** (e.g., `https://github.com/username/repo` or `https://codeberg.org/username/repo`). 4. In the sidebar, choose **Provider** (GitHub or Gitea) and paste the **Repository URL** (e.g., `https://github.com/username/repo` or `https://codeberg.org/username/repo`).
@@ -79,7 +79,7 @@ Enable/disable the following GitHub data in your project views:
- Language - Language
- Downloads - Downloads
- Stars / Forks / Issues - Stars / Forks / Issues
- GitHub Owner - Repo Owner
### Archive Settings ### Archive Settings
@@ -94,8 +94,8 @@ Toggle components for the project archive view:
To connect a project to GitHub: To connect a project to GitHub:
1. Paste the full repo URL into the **GitHub URL** field when editing a Project. 1. Paste the full repo URL into the **Repository URL** field when editing a Project.
2. Make sure your GitHub repo has a release with a `.zip` asset or a valid `zipball_url`. 2. Make sure your repo has a release with a `.zip` asset or a valid `zipball_url`.
### Optional: Add a GitHub API Token ### Optional: Add a GitHub API Token
@@ -194,14 +194,14 @@ Each project includes social sharing links for:
## Support & Contributions ## Support & Contributions
Have a feature request or bug report? Open an issue on [GitHub](https://github.com/robertdevore/projects-portfolio/issues) or contribute via pull request. Have a feature request or bug report? Open an issue on [the repo](https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio/issues) or contribute via pull request.
## License ## License
Projects for WordPress® is licensed under the [GNU GPL v2+](http://www.gnu.org/licenses/gpl-2.0.txt). Projects for WordPress is licensed under the [GNU GPL v2+](http://www.gnu.org/licenses/gpl-2.0.txt).
## Author ## Author
Built and maintained by [Robert DeVore](https://www.robertdevore.com). Built and maintained by [Keith Solomon](https://keithsolomon.net).
Follow on [Twitter](https://twitter.com/deviorobert) or sponsor development on [GitHub Sponsors](https://github.com/sponsors/robertdevore). Follow on [Twitter](https://twitter.com/KeithInWPG).
+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 ) { function projects_portfolio_render_meta_box( $post ) {
wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' ); 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 ); $provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
if ( '' === $provider ) { if ( '' === $provider ) {
$provider = 'github'; $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 ); $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 ); $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> <p>
<label for="projects_portfolio_provider"><?php esc_html_e( 'Provider', 'projects-wp' ); ?></label> <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%;"> <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" /> <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> <span class="description"><?php esc_html_e( 'Leave blank to use the default from Settings.', 'projects-wp' ); ?></span>
</p> </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> <script>
(function(){ (function(){
var sel = document.getElementById('projects_portfolio_provider'); var sel = document.getElementById('projects_portfolio_provider');
if (!sel) return; if (sel) {
var gitea = document.querySelector('.projects-portfolio-gitea-only'); var gitea = document.querySelector('.projects-portfolio-gitea-only');
function toggle(){ function toggleGitea(){
if (!gitea) return; if (!gitea) return;
gitea.style.display = sel.value === 'gitea' ? '' : 'none'; gitea.style.display = sel.value === 'gitea' ? '' : 'none';
} }
sel.addEventListener('change', toggle); sel.addEventListener('change', toggleGitea);
toggle(); 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> </script>
<?php <?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. * 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 ); 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'] ) ) { if ( isset( $_POST['projects_portfolio_provider'] ) ) {
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github'; $provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
update_post_meta( $post_id, '_projects_portfolio_provider', $provider ); 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'] ) ) { 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'] ) ) ); 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' ); add_action( 'save_post', 'projects_portfolio_save_meta_box' );
+32 -10
View File
@@ -21,6 +21,7 @@
border-radius: 50%; border-radius: 50%;
text-decoration: none; text-decoration: none;
transition: background-color 0.3s; transition: background-color 0.3s;
overflow: hidden;
} }
.project-social-sharing a:hover { .project-social-sharing a:hover {
@@ -35,6 +36,14 @@
transition: stroke 0.3s; transition: stroke 0.3s;
} }
/* Tabler-style icons include a viewport <rect> with no fill; without
`stroke="none"` it inherits the parent's stroke color and renders as
a square outline inside the round button. Suppress that stroke on
the rect alone. */
.project-social-sharing svg rect {
stroke: none;
}
.project-social-sharing a:hover svg { .project-social-sharing a:hover svg {
stroke: #ffffff; stroke: #ffffff;
} }
@@ -82,16 +91,28 @@
.project-owner img { .project-owner img {
display: block; display: block;
margin: 0 auto 0; margin: 0;
max-width: 50px; width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
} }
.project-owner span:last-of-type { .project-owner-avatar {
display: flex; flex: 0 0 auto;
justify-content: space-between; display: inline-flex;
flex-direction: column; align-items: center;
flex: 1; justify-content: center;
align-items: end; }
.project-owner-name {
flex: 1 1 auto;
min-width: 0;
}
.project-owner-follow {
flex: 0 0 auto;
} }
.project-meta-table th { .project-meta-table th {
@@ -119,7 +140,7 @@
color: #FFF; color: #FFF;
} }
.project-buttons a.button.project-github-button { .project-buttons a.button.project-repo-button {
display: block; display: block;
background-color: #e0e0e0; background-color: #e0e0e0;
color: #333; color: #333;
@@ -128,9 +149,10 @@
margin: 12px 0; margin: 12px 0;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
border: 1px solid #c9c9c9;
} }
.project-buttons a.button.project-github-button:hover { .project-buttons a.button.project-repo-button:hover {
background-color: #c9c9c9; background-color: #c9c9c9;
color: #333; color: #333;
} }
+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-facebook"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
</svg>

After

Width:  |  Height:  |  Size: 369 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-linkedin"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect width="4" height="12" x="2" y="9" />
<circle cx="4" cy="4" r="2" />
</svg>

After

Width:  |  Height:  |  Size: 460 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-mail"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>

After

Width:  |  Height:  |  Size: 394 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-pin"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 17v5" />
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 525 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-share-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
</svg>

After

Width:  |  Height:  |  Size: 497 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-twitter"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
</svg>

After

Width:  |  Height:  |  Size: 438 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-message-circle"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
</svg>

After

Width:  |  Height:  |  Size: 340 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.460.0 - ISC -->
<svg
class="lucide lucide-twitter"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z" />
</svg>

After

Width:  |  Height:  |  Size: 438 B

+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(); 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 * GitHub API call for owner data
* *
+7 -1
View File
@@ -186,7 +186,13 @@ class Gitea_Provider implements Repository_Provider {
return null; return null;
} }
$release = $body[0]; // Gitea's /releases/latest returns a single release OBJECT (associative array),
// not a list. Some endpoints / older Gitea versions may return a list; handle both.
$release = isset( $body['tag_name'] ) ? $body : ( $body[0] ?? null );
if ( ! is_array( $release ) || empty( $release['tag_name'] ?? '' ) ) {
return null;
}
set_transient( $cache_key, $release, HOUR_IN_SECONDS ); set_transient( $cache_key, $release, HOUR_IN_SECONDS );
return $release; return $release;
} }
+22 -21
View File
@@ -11,7 +11,7 @@
* Plugin Name: Projects Portfolio * Plugin Name: Projects Portfolio
* Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality. * Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.
* Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio * Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
* Version: 1.1.1 * Version: 1.2.0
* Author: Keith Solomon * Author: Keith Solomon
* Author URI: https://keithsolomon.net * Author URI: https://keithsolomon.net
* License: GPL-2.0+ * License: GPL-2.0+
@@ -39,7 +39,7 @@ $myUpdateChecker = PucFactory::buildUpdateChecker(
// PUC's plain JSON metadata mode, so we skip the call rather than throw. // PUC's plain JSON metadata mode, so we skip the call rather than throw.
// Current plugin version. // Current plugin version.
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' ); define( 'PROJECTS_PORTFOLIO_VERSION', '1.2.0' );
// Add the required files. // Add the required files.
require 'admin/admin-settings.php'; require 'admin/admin-settings.php';
@@ -112,7 +112,10 @@ function projects_portfolio_handle_download_redirect() {
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count ); update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
// Redirect to the release ZIP on the project's host. // Redirect to the release ZIP on the project's host.
wp_safe_redirect( esc_url_raw( $download_url ) ); // Use wp_redirect() (not wp_safe_redirect()) because the destination
// is an external host the user has explicitly configured — wp_safe_redirect
// would reject it and fall back to admin_url().
wp_redirect( esc_url_raw( $download_url ), 302 ); // phpcs:ignore
exit; exit;
} else { } else {
wp_die( wp_die(
@@ -268,16 +271,6 @@ function projects_portfolio_enqueue_project_styles() {
wp_enqueue_style( 'projects-wp-styles' ); wp_enqueue_style( 'projects-wp-styles' );
} }
} }
if ( is_singular( 'projects' ) ) {
wp_enqueue_script(
'projects-wp-buttons',
plugin_dir_url( __FILE__ ) . 'assets/js/buttons.js',
array(),
PROJECTS_PORTFOLIO_VERSION,
true
);
}
} }
add_action( 'wp_enqueue_scripts', 'projects_portfolio_enqueue_project_styles' ); add_action( 'wp_enqueue_scripts', 'projects_portfolio_enqueue_project_styles' );
@@ -351,39 +344,47 @@ function projects_portfolio_social_sharing_buttons( $project_id ) {
$html = '<div class="project-social-sharing">'; $html = '<div class="project-social-sharing">';
$svg_icon = function ( string $name, string $file ) use ( &$html ) {
// Render each social icon as an <img> referencing a real .svg file
// shipped under assets/icons/. This bypasses any kses data: URI stripping
// and ensures the icon loads as a normal image via standard img rules.
$src = plugins_url( 'assets/icons/' . $file, __FILE__ );
$html .= '<img src="' . esc_url( $src ) . '" alt="' . esc_attr( $name ) . '" width="20" height="20" style="width:20px;height:20px;display:inline-block;vertical-align:middle;" />';
};
// Facebook. // Facebook.
$html .= '<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '" target="_blank" rel="noopener noreferrer">'; $html .= '<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-facebook" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="0" y="0" width="24" height="24" fill="none"/><path d="M7 10v-3a1 1 0 0 1 1 -1h3v-4h4v4h3a1 1 0 0 1 1 1v3h-4v10h-4v-10h-3" /></svg>'; $svg_icon( 'Facebook', 'facebook.svg' );
$html .= '</a>'; $html .= '</a>';
// X (Twitter). // X (Twitter).
$html .= '<a href="https://twitter.com/intent/tweet?text=' . $title . '&url=' . $url . '" target="_blank" rel="noopener noreferrer">'; $html .= '<a href="https://twitter.com/intent/tweet?text=' . $title . '&url=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-x" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 4l10 16m0 -16l-10 16" /></svg>'; $svg_icon( 'X', 'x.svg' );
$html .= '</a>'; $html .= '</a>';
// LinkedIn. // LinkedIn.
$html .= '<a href="https://www.linkedin.com/sharing/share-offsite/?url=' . $url . '" target="_blank" rel="noopener noreferrer">'; $html .= '<a href="https://www.linkedin.com/sharing/share-offsite/?url=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-linkedin" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="4" y="4" width="16" height="16" rx="2" /><line x1="8" y1="11" x2="8" y2="16" /><line x1="8" y1="8" x2="8" y2="8.01" /><line x1="12" y1="16" x2="12" y2="11" /><path d="M16 16v-3a2 2 0 0 0 -4 0" /></svg>'; $svg_icon( 'LinkedIn', 'linkedin.svg' );
$html .= '</a>'; $html .= '</a>';
// Reddit. // Reddit.
$html .= '<a href="https://www.reddit.com/submit?url=' . $url . '&title=' . $title . '" target="_blank" rel="noopener noreferrer">'; $html .= '<a href="https://www.reddit.com/submit?url=' . $url . '&title=' . $title . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-reddit" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><circle cx="12" cy="12" r="9" /><path d="M12 12m-6 0a6 6 0 1 0 12 0a6 6 0 1 0 -12 0" /><path d="M12 12h0" /><path d="M8.5 13c.667 .667 1.333 1 2 1s1.333 -.333 2 -1" /></svg>'; $svg_icon( 'Reddit', 'reddit.svg' );
$html .= '</a>'; $html .= '</a>';
// WhatsApp. // WhatsApp.
$html .= '<a href="https://wa.me/?text=' . $title . '%20' . $url . '" target="_blank" rel="noopener noreferrer">'; $html .= '<a href="https://www.whatsapp.com/send?text=' . $title . '%20' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-whatsapp" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21l1.65 -4.75a9 9 0 1 1 3.85 3.85z" /><path d="M9 10c1 2 3 3 5 4" /><path d="M9 13c1 1 2 2 4 3" /></svg>'; $svg_icon( 'WhatsApp', 'whatsapp.svg' );
$html .= '</a>'; $html .= '</a>';
// Pinterest. // Pinterest.
$html .= '<a href="https://pinterest.com/pin/create/button/?url=' . $url . '&description=' . $title . '" target="_blank" rel="noopener noreferrer">'; $html .= '<a href="https://pinterest.com/pin/create/button/?url=' . $url . '&description=' . $title . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-pinterest" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><circle cx="12" cy="12" r="9" /><path d="M8 17c1 -2 1.5 -4 .5 -6s-.5 -4 .5 -6" /><path d="M15 11c.667 -1 1.667 -2.333 3 -4" /></svg>'; $svg_icon( 'Pinterest', 'pinterest.svg' );
$html .= '</a>'; $html .= '</a>';
// Email. // Email.
$html .= '<a href="mailto:?subject=' . $title . '&body=' . $url . '" target="_blank" rel="noopener noreferrer">'; $html .= '<a href="mailto:?subject=' . $title . '&body=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-mail" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7l9 6l9 -6" /><rect x="3" y="5" width="18" height="14" rx="2" /></svg>'; $svg_icon( 'Email', 'mail.svg' );
$html .= '</a>'; $html .= '</a>';
$html .= '</div>'; $html .= '</div>';
+69 -35
View File
@@ -4,7 +4,7 @@ $is_fse_theme = wp_theme_has_theme_json() && function_exists( 'wp_is_block_theme
if ( $is_fse_theme ) { if ( $is_fse_theme ) {
wp_head(); wp_head();
echo do_blocks( '<!-- wp:template-part {"slug":"header"} /-->' ); echo do_blocks( '<!-- wp:template-part {"slug":"header"} /-->' ); // phpcs:ignore
} else { } else {
get_header(); get_header();
} }
@@ -13,23 +13,31 @@ $settings = projects_portfolio_settings();
$project_id = get_the_ID(); $project_id = get_the_ID();
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true ); $download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true ); $is_website = projects_portfolio_is_website( $project_id );
if ( '' === $repo_url ) {
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 ); $repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
}
$repo_data = projects_portfolio_get_repo_data( $project_id );
$version = projects_portfolio_get_version( $project_id );
$owner = projects_portfolio_get_owner( $project_id );
$owner_avatar = $repo_data['owner']['avatar_url'] ?? '';
$owner_name = $repo_data['owner']['login'] ?? '';
$owner_url = $repo_data['owner']['html_url'] ?? '';
$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 ) : '';
} }
$repo_data = projects_portfolio_get_repo_data( $project_id );
$version = projects_portfolio_get_version( $project_id );
$owner = projects_portfolio_get_owner( $project_id );
$owner_avatar = $repo_data['owner']['avatar_url'] ?? '';
$owner_name = $repo_data['owner']['login'] ?? '';
$owner_url = $repo_data['owner']['html_url'] ?? '';
$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 ) { if ( $last_updated ) {
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) ); $last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
} }
@@ -49,6 +57,13 @@ if ( $last_updated ) {
</div> </div>
<div class="project-main-content"> <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"> <div class="project-description">
<h1 class="project-title"> <h1 class="project-title">
<?php <?php
@@ -68,13 +83,22 @@ if ( $last_updated ) {
<a href="<?php echo esc_url( site_url( '/download/' . $project_id ) ); ?>" class="button project-download-button"> <a href="<?php echo esc_url( site_url( '/download/' . $project_id ) ); ?>" class="button project-download-button">
<?php esc_html_e( 'Download Now', 'projects-wp' ); ?> <?php esc_html_e( 'Download Now', 'projects-wp' ); ?>
</a> </a>
<?php if ( $repo_url ) : ?> <?php if ( $is_website ) : ?>
<a href="<?php echo esc_url( $browse_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer"> <?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' ); ?> <?php esc_html_e( 'View Repo', 'projects-wp' ); ?>
</a> </a>
<?php else : ?> <?php else : ?>
<p><?php esc_html_e( 'Repository:', 'projects-wp' ); ?> <?php esc_html_e( 'No repository linked.', 'projects-wp' ); ?></p> <p><?php esc_html_e( 'Repository:', 'projects-wp' ); ?> <?php esc_html_e( 'No repository linked.', 'projects-wp' ); ?></p>
<?php endif; ?> <?php endif; ?>
<?php do_action( 'projects_after_download_button', $project_id ); ?>
</div> </div>
<div class="project-meta"> <div class="project-meta">
@@ -83,6 +107,14 @@ if ( $last_updated ) {
?> ?>
<table class="project-meta-table"> <table class="project-meta-table">
<tbody> <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'] ) : ?> <?php if ( $settings['templates']['version'] ) : ?>
<tr> <tr>
<th><?php esc_html_e( 'Version:', 'projects-wp' ); ?></th> <th><?php esc_html_e( 'Version:', 'projects-wp' ); ?></th>
@@ -115,43 +147,45 @@ if ( $last_updated ) {
<?php endif; ?> <?php endif; ?>
<?php <?php
if ( ! empty( $repo_data ) ) : if ( ! empty( $repo_data ) ) :
if ( isset( $repo_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) : ?> if ( isset( $repo_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) :
?>
<tr> <tr>
<th><?php esc_html_e( 'Stars:', 'projects-wp' ); ?></th> <th><?php esc_html_e( 'Stars:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $repo_data['stargazers_count'] ); ?></td> <td><?php esc_html_e( $repo_data['stargazers_count'] ); ?></td>
</tr> </tr>
<?php endif; <?php
if ( isset( $repo_data['forks_count'] ) && $settings['templates']['forks'] ) : ?> endif;
if ( isset( $repo_data['forks_count'] ) && $settings['templates']['forks'] ) :
?>
<tr> <tr>
<th><?php esc_html_e( 'Forks:', 'projects-wp' ); ?></th> <th><?php esc_html_e( 'Forks:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $repo_data['forks_count'] ); ?></td> <td><?php esc_html_e( $repo_data['forks_count'] ); ?></td>
</tr> </tr>
<?php endif; <?php
if ( isset( $repo_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) : ?> endif;
if ( isset( $repo_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) :
?>
<tr> <tr>
<th><?php esc_html_e( 'Issues:', 'projects-wp' ); ?></th> <th><?php esc_html_e( 'Issues:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $repo_data['open_issues_count'] ); ?></td> <td><?php esc_html_e( $repo_data['open_issues_count'] ); ?></td>
</tr> </tr>
<?php endif; <?php
endif; endif;
endif;
endif; // end repo-mode meta rows (else branch of $is_website).
?> ?>
</tbody> </tbody>
</table> </table>
</div> </div>
<?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?> <?php if ( ! $is_website && $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
<a href="<?php echo esc_url( $browse_url ); ?>">
<div class="project-owner"> <div class="project-owner">
<span> <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>
<img src="<?php echo esc_url( $owner_avatar ); ?>" alt="<?php esc_attr_e( $owner_name ); ?>" class="owner-avatar" style="width: 50px; height: 50px; border-radius: 50%;" /> <span class="project-owner-name">
<a href="<?php echo esc_url( $owner_url ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $owner_name ); ?></a>
</span> </span>
<span> <span class="project-owner-follow">
<strong> <a class="button project-follow-button" href="<?php echo esc_url( $owner_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Follow', 'projects-wp' ); ?></a>
<a href="<?php echo esc_url( $owner_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( $owner_name ); ?></a>
</strong>
</span>
<span>
<a class="github-button" href="https://github.com/<?php esc_html_e( $owner_name ); ?>" data-show-count="true" data-color-scheme="no-preference: light; light: light; dark: dark;" data-size="large" aria-label="Follow @<?php esc_html_e( $owner_name ); ?> on GitHub"><?php esc_attr_e( 'Follow', 'projects-wp' ) ?></a>
</span> </span>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -165,7 +199,7 @@ if ( $last_updated ) {
<?php <?php
if ( $is_fse_theme ) { if ( $is_fse_theme ) {
wp_footer(); wp_footer();
echo do_blocks( '<!-- wp:template-part {"slug":"footer"} /-->' ); echo do_blocks( '<!-- wp:template-part {"slug":"footer"} /-->' ); // phpcs:ignore
} else { } else {
get_footer(); get_footer();
} }
+23
View File
@@ -136,6 +136,29 @@ class Gitea_Provider_Test extends \PHPUnit\Framework\TestCase {
); );
} }
public function test_release_object_shape_picks_first_tag_from_object(): void {
// Gitea's /releases/latest returns a single release OBJECT (associative array),
// not a list. The implementation must accept either shape.
$body = json_encode( [
'tag_name' => 'v2.1.0',
'name' => 'v2.1.0',
'assets' => [
[ 'name' => 'release.zip', 'browser_download_url' => 'https://example/release.zip' ],
],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->twice() // get_release_url and get_latest_version both fetch
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
$provider = $this->make();
$this->assertSame( 'https://example/release.zip', $provider->get_release_url() );
$this->assertSame( 'v2.1.0', $provider->get_latest_version() );
}
public function test_release_url_empty_array_returns_null(): void { public function test_release_url_empty_array_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' ) \Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once() ->once()
+111
View File
@@ -142,4 +142,115 @@ class Metabox_Test extends \PHPUnit\Framework\TestCase {
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing ); $this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted ); $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' ) ) { if ( ! function_exists( 'esc_url_raw' ) ) {
function esc_url_raw( $url ) { return $url; } 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. // Minimal WP_Error stub if Brain\Monkey doesn't supply one.
if ( ! class_exists( 'WP_Error' ) ) { if ( ! class_exists( 'WP_Error' ) ) {