50 Commits
Author SHA1 Message Date
Keith Solomon 9b6aa8a6e8 🔵 other: Bump version
Release / Build & publish plugin zip (push) Successful in 7s
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
Release / Build & publish plugin zip (push) Successful in 7s
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'
Release / Build & publish plugin zip (push) Successful in 7s
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
Release / Build & publish plugin zip (push) Successful in 7s
2026-08-12 17:17:43 -05:00
Keith Solomon 426d60b692 Fix avatar and social icons with bulletproof rendering
Release / Build & publish plugin zip (push) Successful in 7s
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
Release / Build & publish plugin zip (push) Successful in 7s
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
Release / Build & publish plugin zip (push) Successful in 7s
- 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
Release / Build & publish plugin zip (push) Successful in 6s
- 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
Release / Build & publish plugin zip (push) Successful in 7s
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
Release / Build & publish plugin zip (push) Successful in 7s
- .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
Release / Build & publish plugin zip (push) Successful in 7s
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
Release / Build & publish plugin zip (push) Successful in 8s
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
Release / Build & publish plugin zip (push) Successful in 7s
2026-08-11 16:52:27 -05:00
Keith Solomon 365a3898b9 Track PUC's bundled vendor/ in git (formerly ignored)
Release / Build & publish plugin zip (push) Successful in 7s
2026-08-11 16:10:19 -05:00
Keith Solomon 2bbd2dc7c8 Update bundled plugin-update-checker to v5.7 and remove broken setBranch() call
The bundled PUC was missing the setBranch() method on the main UpdateChecker
class (it's only on Vcs\PluginUpdateChecker via the VcsCheckerMethods trait).
When called with a non-VCS URL like the Gitea update URL, this caused a fatal
error at plugin activation.

Updating to PUC v5.7 (May 2026) which is the latest upstream release. The
setBranch() call is removed because PUC doesn't recognize Gitea as a VCS host;
the plugin falls back to PUC's plain JSON metadata mode, which is fine for a
plugin hosted on a self-managed repo.
2026-08-11 16:08:20 -05:00
Keith Solomon 93af230406 Make asset upload resilient (warn on duplicate, don't fail)
Release / Build & publish plugin zip (push) Successful in 6s
2026-08-11 13:48:03 -05:00
Keith Solomon c98e46437e Make release creation idempotent (lookup existing release by tag first)
Release / Build & publish plugin zip (push) Successful in 6s
2026-08-11 13:46:16 -05:00
Keith Solomon 3bae89a203 Extract release JSON building into helper script (avoid YAML/bash heredoc trap)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:41:38 -05:00
Keith Solomon 123c0dbb7b Fix bash heredoc-inside-command-substitution by writing to temp file
Release / Build & publish plugin zip (push) Failing after 6s
2026-08-11 13:27:24 -05:00
Keith Solomon 57e1f63bcf Use heredoc for embedded Python (fix IndentationError)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:24:44 -05:00
Keith Solomon bea4875b05 Branch release step on host (github.com vs Gitea)
Release / Build & publish plugin zip (push) Failing after 6s
2026-08-11 13:18:49 -05:00
Keith Solomon 343f647770 Call Gitea REST API directly for releases (Gitea Actions compat)
Release / Build & publish plugin zip (push) Successful in 6s
2026-08-11 13:15:31 -05:00
Keith Solomon 7184d998e6 Use gh CLI for release creation (GHES compatibility)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:08:40 -05:00
Keith Solomon 189bde2008 Use upload-artifact@v3 for GHES compatibility
Release / Build & publish plugin zip (push) Failing after 35s
2026-08-11 13:05:35 -05:00
Keith Solomon a090527037 Use Python zipfile in build step (zip CLI not on runner)
Release / Build & publish plugin zip (push) Failing after 7s
2026-08-11 13:00:49 -05:00
Keith Solomon 1595327a80 Point old 'How to Zip' section to automated workflow
Release / Build & publish plugin zip (push) Failing after 37s
2026-08-11 07:00:44 -05:00
Keith Solomon b4b6158f2d Fix double-v in artifact name 2026-08-11 07:00:28 -05:00
Keith Solomon 0e56de3c56 Bump version to 1.1.1 for first automated release 2026-08-11 06:52:25 -05:00
Keith Solomon f9b8611519 Document automated release process in README 2026-08-11 06:50:23 -05:00
Keith Solomon 84e5119c41 Add GitHub Actions release workflow 2026-08-11 06:48:35 -05:00
Keith Solomon 67a28e0037 Add release workflow implementation plan
4-task plan: workflow file, README section, version bump to 1.1.1,
final verification. No PHP behavior changes; PHPUnit suite unaffected.
2026-08-11 06:45:10 -05:00
Keith Solomon fb6c9d4d61 Add design spec: GitHub Actions release-zip workflow
Single workflow file at .github/workflows/release.yml that triggers
on v* tag pushes, assembles projects-portfolio-v<version>.zip from
an allowlist of runtime files, uploads as a workflow artifact, and
attaches to a matching GitHub release.
2026-08-11 06:43:47 -05:00
Keith Solomon 0480abe292 fix: clear legacy _projects_portfolio_github_url on provider switch / empty repo URL 2026-08-09 23:49:06 -05:00
Keith Solomon 981c29ab39 Regenerate translation template for Gitea strings 2026-08-09 23:35:45 -05:00
Keith Solomon 5913c8799f Document Gitea support in README 2026-08-09 23:30:53 -05:00
Keith Solomon d250f6b89f Wire provider includes and bump version to 1.1.0 2026-08-09 23:28:47 -05:00
Keith Solomon 62a049b2c2 Route download redirect through provider interface 2026-08-09 23:26:02 -05:00
Keith Solomon e25431a25d Route REST API responses through provider interface 2026-08-09 23:23:57 -05:00
Keith Solomon 9803b497ab Route single-projects template through provider interface 2026-08-09 23:21:06 -05:00
Keith Solomon d8ce719433 Add provider dropdown and per-project Gitea base URL to metabox 2026-08-09 23:17:51 -05:00
Keith Solomon e60a9eafb3 Surface Gitea settings via projects_portfolio_settings() and test 2026-08-09 23:12:37 -05:00
Keith Solomon 925ef184b9 Add Gitea base URL + token fields to settings page 2026-08-09 23:09:50 -05:00
Keith Solomon 40793cd4bb Add provider-aware helpers; mark legacy wrappers deprecated 2026-08-09 23:06:24 -05:00
Keith Solomon e3b27e8974 Add provider factory with meta + option wiring 2026-08-09 23:02:20 -05:00
Keith Solomon 62a20ed514 Add Gitea provider adapter with normalization and tests 2026-08-09 22:56:16 -05:00
keith 28789f6295 Add GitHub provider adapter with full test coverage 2026-08-09 22:48:58 -05:00
Keith Solomon 4a9f43c850 Scaffold provider interface and test bootstrap 2026-08-09 22:35:23 -05:00
Keith Solomon 730893c1f0 Add Gitea support implementation plan
15-task plan with TDD-style steps, WP PHPUnit tests, and a
provider-interface architecture. Tasks cover scaffolding, both
adapters, factory, settings, metabox, template, REST, download
redirect, version bump, README, translations, and final smoke
testing.
2026-08-09 22:29:13 -05:00
Keith Solomon e924b4833a Add design spec: Gitea support for Projects Portfolio
Adds a provider interface with GitHub and Gitea adapters so each
project can link to either provider. Preserves existing GitHub
behavior bit-for-bit, adds lazy migration from legacy meta, and
ships WP PHPUnit tests.
2026-08-09 22:23:27 -05:00
95 changed files with 7839 additions and 488 deletions
+157
View File
@@ -0,0 +1,157 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Build & publish plugin zip
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build plugin zip
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
OUT="dist/projects-portfolio-v${VERSION}.zip"
rm -rf dist
mkdir -p dist
# Use Python's stdlib zipfile module — guaranteed to be on ubuntu-latest.
python3 - "$OUT" <<'PYEOF'
import os
import sys
import zipfile
out_path = sys.argv[1]
allowed = [
"projects-portfolio.php",
"README.md",
"LICENSE",
"admin",
"assets",
"includes",
"languages",
"templates",
]
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for entry in allowed:
if os.path.isdir(entry):
for root, _, files in os.walk(entry):
for name in files:
abs_path = os.path.join(root, name)
arcname = os.path.join("projects-portfolio", abs_path)
zf.write(abs_path, arcname)
elif os.path.isfile(entry):
zf.write(entry, os.path.join("projects-portfolio", entry))
else:
sys.exit(f"Allowlisted path missing: {entry}")
PYEOF
echo "Built $OUT"
python3 -c "import zipfile; zf=zipfile.ZipFile('$OUT'); print('\n'.join(zf.namelist()))"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: projects-portfolio-${{ github.ref_name }}
path: dist/projects-portfolio-*.zip
if-no-files-found: error
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
GH_API_URL: ${{ github.api_url }}
GH_SHA: ${{ github.sha }}
run: |
set -euo pipefail
zip_path=( dist/projects-portfolio-*.zip )
zip_path="${zip_path[0]}"
asset_name=$(basename "$zip_path")
if [[ "$GH_API_URL" == "https://api.github.com" ]]; then
# ---- GitHub.com path ----
if [ -z "${GITHUB_TOKEN:-}" ]; then
echo "::error::GITHUB_TOKEN is not available on this runner. Check repo permissions." >&2
exit 1
fi
payload=$(python3 scripts/release-helper.py build-payload --github)
# Look up existing release by tag (idempotent on re-run).
release_json=$(curl -fsS \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${GH_API_URL}/repos/${REPO}/releases/tags/${TAG}" \
|| true)
if [ -n "$release_json" ] && echo "$release_json" | python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.stdin.read()).get("id") else 1)' 2>/dev/null; then
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
upload_url=$(echo "$release_json" | python3 scripts/release-helper.py extract-upload-url)
echo "Reusing existing GitHub release id=$release_id for tag $TAG"
else
release_json=$(curl -fsS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
-d "$payload" \
"${GH_API_URL}/repos/${REPO}/releases")
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
upload_url=$(echo "$release_json" | python3 scripts/release-helper.py extract-upload-url)
echo "Created GitHub release id=$release_id"
fi
curl -fsS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/zip" \
--data-binary "@${zip_path}" \
"${upload_url}?name=${asset_name}"
echo "Uploaded GitHub asset: ${asset_name}"
else
# ---- Gitea path ----
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::GITEA_TOKEN secret is not set. Create a personal access token in Gitea with 'write:repository' scope and add it as a repository secret named GITEA_TOKEN." >&2
exit 1
fi
if [ -z "${GH_API_URL:-}" ] || [[ "$GH_API_URL" == "https://git.keithsolomon.net"* ]]; then
API="https://git.keithsolomon.net/api/v1"
else
API="${GH_API_URL%/}/api/v1"
fi
payload=$(python3 scripts/release-helper.py build-payload)
# Look up existing release by tag (idempotent on re-run).
release_json=$(curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|| true)
if [ -n "$release_json" ] && echo "$release_json" | python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.stdin.read()).get("id") else 1)' 2>/dev/null; then
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
echo "Reusing existing Gitea release id=$release_id for tag $TAG"
else
release_json=$(curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" \
"${API}/repos/${REPO}/releases")
release_id=$(echo "$release_json" | python3 scripts/release-helper.py extract-id)
echo "Created Gitea release id=$release_id"
fi
curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${zip_path}" \
"${API}/repos/${REPO}/releases/${release_id}/assets?name=${asset_name}" \
|| echo "::warning::Asset upload failed (asset may already exist); continuing."
echo "Gitea asset step complete: ${asset_name}"
fi
+5
View File
@@ -2,3 +2,8 @@
.vscode/
docs/
notes/
vendor/
phpunit.xml
.phpunit.result.cache
# But DO track the vendor/ that ships with the bundled plugin-update-checker library.
!/includes/plugin-update-checker/vendor/
+53 -26
View File
@@ -1,13 +1,13 @@
# 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
**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.
- Connect each project to a GitHub repository.
- Connect each project to a git repository.
- Automatically fetch and display data like:
- Latest version
- Stars, forks, issues
@@ -27,14 +27,14 @@ Create a beautifully structured, developer-first showcase directory for your Wor
### From GitHub
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.
### From [keithsolomon.net](https://keithsolomon.net)
1. Visit the official plugin page: [Projects Portfolio](https://keithsolomon.net/projects/projects-portfolio).
2. Download the ZIP.
3. Install it via the WordPress® dashboard like above.
3. Install it via the WordPress dashboard like above.
---
@@ -42,11 +42,12 @@ Create a beautifully structured, developer-first showcase directory for your Wor
### 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.
3. Select a **Project Type**: Plugin, Theme, or Pattern.
4. In the sidebar, paste the GitHub Repository URL (e.g., `https://github.com/username/repo`).
5. Publish.
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`).
5. For Gitea projects, optionally set a **Gitea Base URL** to override the global default.
6. Publish.
### Enable Download Link
@@ -55,7 +56,7 @@ Each project automatically gets a unique download endpoint:
Example:
`https://example.com/download/123`
This URL fetches the latest `.zip` release from GitHub and increments the download count.
This URL fetches the latest `.zip` release from the project's host (GitHub or Gitea) and increments the download count.
## Settings Overview
@@ -64,6 +65,8 @@ Navigate to **Projects → Settings** to configure:
### General Settings
- **GitHub API Token** Recommended for authenticated requests (avoids GitHub rate limits).
- **Default Gitea Base URL** Used when a Gitea project doesn't specify one. Defaults to `https://codeberg.org`.
- **Gitea API Token** Recommended for authenticated Gitea requests.
- **Telemetry** Toggle to help improve the plugin.
### Templates Settings
@@ -76,7 +79,7 @@ Enable/disable the following GitHub data in your project views:
- Language
- Downloads
- Stars / Forks / Issues
- GitHub Owner
- Repo Owner
### Archive Settings
@@ -91,8 +94,8 @@ Toggle components for the project archive view:
To connect a project to GitHub:
1. Paste the full repo URL into the **GitHub URL** field when editing a Project.
2. Make sure your GitHub repo has a release with a `.zip` asset or a valid `zipball_url`.
1. Paste the full repo URL into the **Repository URL** field when editing a Project.
2. Make sure your repo has a release with a `.zip` asset or a valid `zipball_url`.
### Optional: Add a GitHub API Token
@@ -102,18 +105,42 @@ To avoid hitting rate limits or improve reliability:
2. Generate a token (no scopes required).
3. Paste it into the settings screen under **GitHub API Token**.
## Connect Your Gitea Repo
To connect a project to a Gitea instance (self-hosted or public):
1. Set **Provider** to **Gitea** in the project editor.
2. Paste the full repository URL into **Repository URL** (e.g., `https://codeberg.org/username/repo`).
3. Optionally override the Gitea base URL for this project (leave blank to use the global default).
4. Make sure the repo has a release with a `.zip` asset, or any tag — the plugin will fall back to the source archive URL.
### Optional: Add a Gitea API Token
To avoid rate limits or improve reliability:
1. In your Gitea instance, go to **Settings → Applications** and generate a token.
2. Paste it into the plugin settings screen under **Gitea API Token**.
## Building a Release
Releases are automated via GitHub Actions. To cut a new release:
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
2. Commit and push to `main`.
3. Tag the release commit: `git tag -a v1.1.1 -m "v1.1.1 — short summary"`.
4. Push the tag: `git push origin v1.1.1`.
The `.github/workflows/release.yml` workflow runs and:
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
- Uploads the zip as a workflow artifact.
- Creates (or updates) the matching GitHub Release with the zip attached.
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
## How to Zip the Plugin for GitHub Releases
When attaching the plugin to a GitHub release:
1. Zip **only the contents of the plugin folder**, not the parent folder.
2. Ensure `projects-portfolio.php` and `/includes`, `/admin`, `/templates`, `/assets` are at the root level of the ZIP.
3. Name the ZIP clearly (e.g. `projects-portfolio-1.0.0.zip`).
4. Go to your GitHub repo → **Releases****New Release**.
5. Tag the release with the version number (e.g., `1.0.0`).
6. Upload your correctly structured ZIP as a release asset.
Your users will be redirected to this file when they use the `/download/` endpoint.
This is now automated. See [Building a Release](#building-a-release) above — pushing a `v*` tag triggers `.github/workflows/release.yml` which builds and publishes the zip.
## REST API Endpoints
@@ -167,14 +194,14 @@ Each project includes social sharing links for:
## 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
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
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).
+27 -2
View File
@@ -41,8 +41,10 @@ function projects_portfolio_render_settings_page() {
}
// Retrieve saved settings.
$api_token = get_option( 'projects_portfolio_github_api_token', '' );
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' );
$api_token = get_option( 'projects_portfolio_github_api_token', '' );
$gitea_api_token = get_option( 'projects_portfolio_gitea_api_token', '' );
$default_gitea_base_url = get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' );
// Templates Settings.
$templates_settings = [
@@ -83,6 +85,27 @@ function projects_portfolio_render_settings_page() {
</tr>
</table>
<h2><?php esc_html_e( 'Gitea Settings', 'projects-wp' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row">
<label for="projects_portfolio_default_gitea_base_url"><?php esc_html_e( 'Default Gitea Base URL', 'projects-wp' ); ?></label>
</th>
<td>
<input type="url" id="projects_portfolio_default_gitea_base_url" name="projects_portfolio_default_gitea_base_url" value="<?php echo esc_attr( $default_gitea_base_url ); ?>" class="regular-text" placeholder="https://codeberg.org" />
<p class="description"><?php esc_html_e( 'Used when a Gitea project has no per-project base URL override.', 'projects-wp' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="projects_portfolio_gitea_api_token"><?php esc_html_e( 'Gitea API Token', 'projects-wp' ); ?></label>
</th>
<td>
<input type="password" id="projects_portfolio_gitea_api_token" name="projects_portfolio_gitea_api_token" value="<?php echo esc_attr( $gitea_api_token ); ?>" class="regular-text" />
</td>
</tr>
</table>
<h2><?php esc_html_e( 'Templates Settings', 'projects-wp' ); ?></h2>
<table class="form-table">
<?php foreach ( $templates_settings as $key => $label ) :
@@ -134,6 +157,8 @@ function projects_portfolio_save_settings() {
}
update_option( 'projects_portfolio_github_api_token', sanitize_text_field( $_POST['projects_portfolio_github_api_token'] ?? '' ) );
update_option( 'projects_portfolio_gitea_api_token', sanitize_text_field( $_POST['projects_portfolio_gitea_api_token'] ?? '' ) );
update_option( 'projects_portfolio_default_gitea_base_url', esc_url_raw( $_POST['projects_portfolio_default_gitea_base_url'] ?? 'https://codeberg.org' ) );
update_option( 'projects_portfolio_share_telemetry', isset( $_POST['projects_portfolio_share_telemetry'] ) ? '1' : '0' );
// Templates Settings Keys.
+87 -15
View File
@@ -6,15 +6,15 @@ if ( ! defined( 'WPINC' ) ) {
}
/**
* Add GitHub Repository URL meta box.
* Add Repository meta box (provider-aware).
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_add_meta_boxes() {
add_meta_box(
'projects_portfolio_github_url',
esc_html__( 'GitHub URL', 'projects-wp' ),
'projects_portfolio_repo',
esc_html__( 'Repository', 'projects-wp' ),
'projects_portfolio_render_meta_box',
'projects',
'side'
@@ -23,27 +23,64 @@ function projects_portfolio_add_meta_boxes() {
add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' );
/**
* Renders the GitHub Repository URL meta box for the Projects WP plugin.
* Render the Repository meta box.
*
* Outputs a label and input field for setting the GitHub repository URL,
* as well as a nonce for security.
*
* @param WP_Post $post The post object currently being edited.
* @param WP_Post $post
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_render_meta_box( $post ) {
wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' );
$github_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
echo '<label for="projects_portfolio_github_url">' . __( 'GitHub Repository URL:', 'projects-wp' ) . '</label>';
echo '<input type="url" id="projects_portfolio_github_url" name="projects_portfolio_github_url" value="' . esc_attr( $github_url ) . '" style="width: 100%;" />';
$provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
if ( '' === $provider ) {
$provider = 'github';
}
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
$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 );
?>
<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%;">
<option value="github" <?php selected( $provider, 'github' ); ?>><?php esc_html_e( 'GitHub', 'projects-wp' ); ?></option>
<option value="gitea" <?php selected( $provider, 'gitea' ); ?>><?php esc_html_e( 'Gitea', 'projects-wp' ); ?></option>
</select>
</p>
<p>
<label for="projects_portfolio_repo_url"><?php esc_html_e( 'Repository URL', 'projects-wp' ); ?></label>
<input type="url" id="projects_portfolio_repo_url" name="projects_portfolio_repo_url" value="<?php echo esc_attr( $repo_url ); ?>" style="width: 100%;" placeholder="https://github.com/owner/repo" />
</p>
<p class="projects-portfolio-gitea-only" <?php echo 'gitea' !== $provider ? 'style="display:none;"' : ''; ?>>
<label for="projects_portfolio_gitea_base_url"><?php esc_html_e( 'Gitea Base URL (optional override)', 'projects-wp' ); ?></label>
<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>
<script>
(function(){
var sel = document.getElementById('projects_portfolio_provider');
if (!sel) return;
var gitea = document.querySelector('.projects-portfolio-gitea-only');
function toggle(){
if (!gitea) return;
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
}
sel.addEventListener('change', toggle);
toggle();
})();
</script>
<?php
}
/**
* Saves the GitHub repository URL meta box data for the Projects WP plugin.
* Save the Repository meta box.
*
* @param int $post_id The ID of the post currently being saved.
* Performs a lazy migration from legacy `_projects_portfolio_github_url`.
*
* @param int $post_id
*
* @since 1.0.0
* @return void
@@ -55,8 +92,43 @@ function projects_portfolio_save_meta_box( $post_id ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( isset( $_POST['projects_portfolio_github_url'] ) ) {
update_post_meta( $post_id, '_projects_portfolio_github_url', esc_url_raw( $_POST['projects_portfolio_github_url'] ) );
$existing_provider = get_post_meta( $post_id, '_projects_portfolio_provider', true );
$existing_repo_url = get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
$legacy_github = get_post_meta( $post_id, '_projects_portfolio_github_url', true );
// Lazy migration: legacy URL present, no explicit choice.
if ( '' === $existing_provider && '' === $existing_repo_url && '' !== $legacy_github ) {
update_post_meta( $post_id, '_projects_portfolio_provider', 'github' );
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
}
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
}
if ( isset( $_POST['projects_portfolio_repo_url'] ) ) {
$repo_url = esc_url_raw( wp_unslash( $_POST['projects_portfolio_repo_url'] ) );
update_post_meta( $post_id, '_projects_portfolio_repo_url', $repo_url );
// Maintain the legacy key for backward-compat reads (only when this is a GitHub project).
$provider_now = get_post_meta( $post_id, '_projects_portfolio_provider', true );
if ( 'github' === $provider_now ) {
if ( '' === $repo_url ) {
// Clearing the field on a GitHub project should clear the legacy key too.
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
} else {
update_post_meta( $post_id, '_projects_portfolio_github_url', $repo_url );
}
} else {
// Provider is gitea (or any non-github): ensure no stale GitHub URL can
// resurrect via the legacy fallback chain.
delete_post_meta( $post_id, '_projects_portfolio_github_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'] ) ) );
}
}
add_action( 'save_post', 'projects_portfolio_save_meta_box' );
+20 -16
View File
@@ -27,9 +27,12 @@ function projects_portfolio_get_projects_data( $request ) {
while ( $query->have_posts() ) {
$query->the_post();
$project_id = get_the_ID();
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$github_data = projects_portfolio_get_github_data( $github_url );
$project_id = get_the_ID();
$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_data = projects_portfolio_get_repo_data( $project_id );
// Fetch project-type taxonomy terms (names).
$project_types = wp_get_post_terms( $project_id, 'project-type', [
@@ -44,23 +47,24 @@ function projects_portfolio_get_projects_data( $request ) {
'thumbnail' => get_the_post_thumbnail_url( $project_id, 'large' ),
'download_count' => (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true ),
'download_url' => esc_url( site_url( '/download/' . $project_id ) ),
'github_url' => $github_url,
'github_url' => $repo_url,
'provider' => get_post_meta( $project_id, '_projects_portfolio_provider', true ) ?: 'github',
'github_data' => [
'owner' => [
'avatar_url' => $github_data['owner']['avatar_url'] ?? '',
'name' => $github_data['owner']['login'] ?? '',
'profile' => $github_data['owner']['html_url'] ?? '',
'avatar_url' => $repo_data['owner']['avatar_url'] ?? '',
'name' => $repo_data['owner']['login'] ?? '',
'profile' => $repo_data['owner']['html_url'] ?? '',
],
'last_updated' => ! empty( $github_data['updated_at'] )
? date_i18n( get_option( 'date_format' ), strtotime( $github_data['updated_at'] ) )
'last_updated' => ! empty( $repo_data['updated_at'] )
? date_i18n( get_option( 'date_format' ), strtotime( $repo_data['updated_at'] ) )
: '',
'language' => $github_data['language'] ?? '',
'license' => $github_data['license']['name'] ?? 'None',
'stars' => $github_data['stargazers_count'] ?? 0,
'forks' => $github_data['forks_count'] ?? 0,
'issues' => $github_data['open_issues_count'] ?? 0,
'language' => $repo_data['language'] ?? '',
'license' => $repo_data['license']['name'] ?? 'None',
'stars' => $repo_data['stargazers_count'] ?? 0,
'forks' => $repo_data['forks_count'] ?? 0,
'issues' => $repo_data['open_issues_count'] ?? 0,
],
'version' => projects_portfolio_get_version_from_github( $github_url ),
'version' => projects_portfolio_get_version( $project_id ),
'project_types' => $project_types,
'content' => get_the_content(),
];
@@ -121,7 +125,7 @@ function projects_portfolio_get_popular_projects( $data ) {
'title' => get_the_title(),
'download_count' => (int) get_post_meta( get_the_ID(), '_projects_portfolio_download_count', true ),
'download_url' => esc_url( site_url( '/download/' . get_the_ID() ) ),
'github_url' => get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ),
'github_url' => ( $url = get_post_meta( get_the_ID(), '_projects_portfolio_repo_url', true ) ?: get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ) ),
'permalink' => get_permalink(),
];
}
+32 -10
View File
@@ -21,6 +21,7 @@
border-radius: 50%;
text-decoration: none;
transition: background-color 0.3s;
overflow: hidden;
}
.project-social-sharing a:hover {
@@ -35,6 +36,14 @@
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 {
stroke: #ffffff;
}
@@ -82,16 +91,28 @@
.project-owner img {
display: block;
margin: 0 auto 0;
max-width: 50px;
margin: 0;
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.project-owner span:last-of-type {
display: flex;
justify-content: space-between;
flex-direction: column;
flex: 1;
align-items: end;
.project-owner-avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
}
.project-owner-name {
flex: 1 1 auto;
min-width: 0;
}
.project-owner-follow {
flex: 0 0 auto;
}
.project-meta-table th {
@@ -119,7 +140,7 @@
color: #FFF;
}
.project-buttons a.button.project-github-button {
.project-buttons a.button.project-repo-button {
display: block;
background-color: #e0e0e0;
color: #333;
@@ -128,9 +149,10 @@
margin: 12px 0;
border-radius: 4px;
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;
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

+18
View File
@@ -0,0 +1,18 @@
{
"name": "keithsolomon/projects-portfolio",
"description": "WordPress plugin — dev-only test dependencies.",
"type": "wordpress-plugin",
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
"brain/monkey": "^2.6",
"yoast/phpunit-polyfills": "^2.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": false
}
}
}
Generated
+2116
View File
File diff suppressed because it is too large Load Diff
+73 -2
View File
@@ -16,8 +16,10 @@ function projects_portfolio_settings() {
if ( $settings === null ) {
$settings = [
'github_api_token' => get_option( 'projects_portfolio_github_api_token', '' ),
'share_telemetry' => get_option( 'projects_portfolio_share_telemetry', '0' ),
'github_api_token' => get_option( 'projects_portfolio_github_api_token', '' ),
'gitea_api_token' => get_option( 'projects_portfolio_gitea_api_token', '' ),
'default_gitea_base_url' => get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' ),
'share_telemetry' => get_option( 'projects_portfolio_share_telemetry', '0' ),
'templates' => [
'version' => get_option( 'projects_portfolio_templates_version', '0' ),
'last_updated' => get_option( 'projects_portfolio_templates_last_updated', '0' ),
@@ -41,6 +43,67 @@ function projects_portfolio_settings() {
return $settings;
}
/**
* Fetch normalized repository data for a project.
*
* @since 1.1.0
* @param int $post_id
* @return array|null
*/
function projects_portfolio_get_repo_data( int $post_id ): ?array {
return projects_portfolio_get_provider( $post_id )->get_repo_data();
}
/**
* Resolve the latest release ZIP download URL for a project.
*
* @since 1.1.0
* @param int $post_id
* @return string|null
*/
function projects_portfolio_get_release_url( int $post_id ): ?string {
return projects_portfolio_get_provider( $post_id )->get_release_url();
}
/**
* Fetch the latest tag name for a project.
*
* @since 1.1.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_version( int $post_id ): string {
return projects_portfolio_get_provider( $post_id )->get_latest_version();
}
/**
* Fetch the repo owner profile data for a project.
*
* @since 1.1.0
* @param int $post_id
* @return array|null
*/
function projects_portfolio_get_owner( int $post_id ): ?array {
$provider = projects_portfolio_get_provider( $post_id );
$repo = $provider->get_repo_data();
$login = $repo['owner']['login'] ?? '';
if ( '' === $login ) {
return null;
}
return $provider->get_owner_data( $login );
}
/**
* Public browse URL for the project's repo.
*
* @since 1.1.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_repo_browse_url( int $post_id ): string {
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
}
/**
* GitHub API call for owner data
*
@@ -50,6 +113,8 @@ function projects_portfolio_settings() {
* @return mixed
*/
function projects_portfolio_github_owner( $owner_name = NULL ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_owner( $post_id )' );
if ( ! $owner_name ) { return; }
$owner = 'https://api.github.com/users/' . $owner_name;
@@ -87,6 +152,8 @@ function projects_portfolio_github_owner( $owner_name = NULL ) {
* @return mixed
*/
function projects_portfolio_get_github_release_url( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_release_url( $post_id )' );
$api_token = get_option( 'projects_portfolio_github_api_token', '' );
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest';
@@ -126,6 +193,8 @@ function projects_portfolio_get_github_release_url( $github_url ) {
* @return array|null Associative array of GitHub repo data on success, or null on failure.
*/
function projects_portfolio_get_github_data( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_repo_data( $post_id )' );
if ( empty( $github_url ) ) {
error_log( 'GitHub URL is empty.' );
return null;
@@ -186,6 +255,8 @@ function projects_portfolio_get_github_data( $github_url ) {
* @return string The version number or 'Unknown'.
*/
function projects_portfolio_get_version_from_github( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_version( $post_id )' );
if ( empty( $github_url ) ) {
return 'Unknown';
}
@@ -4,7 +4,7 @@ namespace YahnisElsts\PluginUpdateChecker\v5;
if ( !class_exists(PucFactory::class, false) ):
class PucFactory extends \YahnisElsts\PluginUpdateChecker\v5p4\PucFactory {
class PucFactory extends \YahnisElsts\PluginUpdateChecker\v5p7\PucFactory {
}
endif;
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(Autoloader::class, false) ):
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker;
if ( !class_exists(Extension::class, false) ):
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker;
if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false) ):
@@ -41,11 +41,11 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
echo '<h3>Configuration</h3>';
echo '<table class="puc-debug-data">';
$this->displayConfigHeader();
$this->row('Slug', htmlentities($this->updateChecker->slug));
$this->row('DB option', htmlentities($this->updateChecker->optionName));
$this->row('Slug', esc_html($this->updateChecker->slug));
$this->row('DB option', esc_html($this->updateChecker->optionName));
$requestInfoButton = $this->getMetadataButton();
$this->row('Metadata URL', htmlentities($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox);
$this->row('Metadata URL', esc_html($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox);
$scheduler = $this->updateChecker->scheduler;
if ( $scheduler->checkPeriod > 0 ) {
@@ -86,14 +86,22 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
echo '<h3>Status</h3>';
echo '<table class="puc-debug-data">';
$state = $this->updateChecker->getUpdateState();
$checkNowButton = '';
$checkButtonId = $this->updateChecker->getUniqueName('check-now-button');
if ( function_exists('get_submit_button') ) {
$checkNowButton = get_submit_button(
'Check Now',
'secondary',
'puc-check-now-button',
false,
array('id' => $this->updateChecker->getUniqueName('check-now-button'))
array('id' => $checkButtonId)
);
} else {
//get_submit_button() is not available in the frontend. Make a button directly.
//It won't look the same without admin styles, but it should still work.
$checkNowButton = sprintf(
'<input type="button" id="%1$s" name="puc-check-now-button" value="%2$s" class="button button-secondary" />',
esc_attr($checkButtonId),
esc_attr('Check Now')
);
}
@@ -107,10 +115,10 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
$this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck));
if ( $state->getCheckedVersion() !== '' ) {
$this->row('Checked version', htmlentities($state->getCheckedVersion()));
$this->row('Checked version', esc_html($state->getCheckedVersion()));
$this->row('Cached update', $state->getUpdate());
}
$this->row('Update checker class', htmlentities(get_class($this->updateChecker)));
$this->row('Update checker class', esc_html(get_class($this->updateChecker)));
echo '</table>';
}
@@ -124,7 +132,7 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
if ( property_exists($update, $field) ) {
$this->row(
ucwords(str_replace('_', ' ', $field)),
isset($update->$field) ? htmlentities($update->$field) : null
isset($update->$field) ? esc_html($update->$field) : null
);
}
}
@@ -162,7 +170,7 @@ if ( !class_exists(Panel::class, false) && class_exists('Debug_Bar_Panel', false
if ( is_object($value) || is_array($value) ) {
//This is specifically for debugging, so print_r() is fine.
//phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
$value = '<pre>' . htmlentities(print_r($value, true)) . '</pre>';
$value = '<pre>' . esc_html(print_r($value, true)) . '</pre>';
} else if ($value === null) {
$value = '<code>null</code>';
}
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker;
if ( !class_exists(PluginExtension::class, false) ):
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker;
if ( !class_exists(PluginPanel::class, false) ):
@@ -12,19 +12,25 @@ if ( !class_exists(PluginPanel::class, false) ):
protected $updateChecker;
protected function displayConfigHeader() {
$this->row('Plugin file', htmlentities($this->updateChecker->pluginFile));
$this->row('Plugin file', esc_html($this->updateChecker->pluginFile));
parent::displayConfigHeader();
}
protected function getMetadataButton() {
$requestInfoButton = '';
$buttonId = $this->updateChecker->getUniqueName('request-info-button');
if ( function_exists('get_submit_button') ) {
$requestInfoButton = get_submit_button(
'Request Info',
'secondary',
'puc-request-info-button',
false,
array('id' => $this->updateChecker->getUniqueName('request-info-button'))
array('id' => $buttonId)
);
} else {
$requestInfoButton = sprintf(
'<input type="button" name="puc-request-info-button" id="%1$s" value="%2$s" class="button button-secondary" />',
esc_attr($buttonId),
esc_attr('Request Info')
);
}
return $requestInfoButton;
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p4\Theme\UpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Theme\UpdateChecker;
if ( !class_exists(ThemePanel::class, false) ):
@@ -13,7 +13,7 @@ if ( !class_exists(ThemePanel::class, false) ):
protected $updateChecker;
protected function displayConfigHeader() {
$this->row('Theme directory', htmlentities($this->updateChecker->directoryName));
$this->row('Theme directory', esc_html($this->updateChecker->directoryName));
parent::displayConfigHeader();
}
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(InstalledPackage::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use LogicException;
use stdClass;
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(OAuthSignature::class, false) ):
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory;
if ( !class_exists(Package::class, false) ):
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\Metadata;
use YahnisElsts\PluginUpdateChecker\v5p7\Metadata;
if ( !class_exists(PluginInfo::class, false) ):
@@ -39,6 +39,7 @@ if ( !class_exists(PluginInfo::class, false) ):
public $downloaded;
public $active_installs;
public $last_updated;
public $autoupdate = false;
public $id = 0; //The native WP.org API returns numeric plugin IDs, but they're not used for anything.
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
if ( !class_exists(Ui::class, false) ):
/**
@@ -187,7 +187,7 @@ if ( !class_exists(Ui::class, false) ):
}
}
wp_redirect(add_query_arg(
wp_safe_redirect(add_query_arg(
array(
'puc_update_check_result' => $status,
'puc_slug' => $this->updateChecker->slug,
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\Update as BaseUpdate;
use YahnisElsts\PluginUpdateChecker\v5p7\Update as BaseUpdate;
if ( !class_exists(Update::class, false) ):
@@ -20,9 +20,10 @@ if ( !class_exists(Update::class, false) ):
public $requires_php = false;
public $icons = array();
public $filename; //Plugin filename relative to the plugins directory.
public $autoupdate = false;
protected static $extraFields = array(
'id', 'homepage', 'tested', 'requires_php', 'upgrade_notice', 'icons', 'filename',
'id', 'homepage', 'tested', 'requires_php', 'upgrade_notice', 'icons', 'filename', 'autoupdate',
);
/**
@@ -86,6 +87,7 @@ if ( !class_exists(Update::class, false) ):
$update->tested = $this->tested;
$update->requires_php = $this->requires_php;
$update->plugin = $this->filename;
$update->autoupdate = $this->autoupdate;
if ( !empty($this->upgrade_notice) ) {
$update->upgrade_notice = $this->upgrade_notice;
@@ -102,7 +104,7 @@ if ( !class_exists(Update::class, false) ):
$update->icons = $icons;
//It appears that the 'default' icon isn't used anywhere in WordPress 4.9,
//but lets set it just in case a future release needs it.
//but let's set it just in case a future release needs it.
if ( !isset($update->icons['default']) ) {
$update->icons['default'] = current($update->icons);
}
@@ -1,10 +1,10 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p4\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
if ( !class_exists(UpdateChecker::class, false) ):
@@ -1,10 +1,10 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p4\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !class_exists(PucFactory::class, false) ):
@@ -86,7 +86,7 @@ if ( !class_exists(PucFactory::class, false) ):
throw new \RuntimeException(sprintf(
'The update checker cannot determine if "%s" is a plugin or a theme. ' .
'This is a bug. Please contact the PUC developer.',
htmlentities($fullPath)
esc_html($fullPath)
));
}
@@ -147,7 +147,7 @@ if ( !class_exists(PucFactory::class, false) ):
*
* Normalize a filesystem path. Introduced in WP 3.9.
* Copying here allows use of the class on earlier versions.
* This version adapted from WP 4.8.2 (unchanged since 4.5.4)
* This version adapted from WP 4.8.2 (unchanged since 4.5.7)
*
* @param string $path Path to normalize.
* @return string Normalized path.
@@ -239,7 +239,7 @@ if ( !class_exists(PucFactory::class, false) ):
//URI was not found so throw an error.
throw new \RuntimeException(
sprintf('Unable to locate URI in header of "%s"', htmlentities($fullPath))
sprintf('Unable to locate URI in header of "%s"', esc_html($fullPath))
);
}
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(Scheduler::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(StateStore::class, false) ):
@@ -77,7 +77,7 @@ if ( !class_exists(StateStore::class, false) ):
* @param Update|null $update
* @return $this
*/
public function setUpdate(Update $update = null) {
public function setUpdate($update = null) {
$this->lazyLoad();
$this->update = $update;
return $this;
@@ -1,7 +1,7 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
if ( !class_exists(Package::class, false) ):
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\Update as BaseUpdate;
use YahnisElsts\PluginUpdateChecker\v5p7\Update as BaseUpdate;
if ( !class_exists(Update::class, false) ):
@@ -1,11 +1,11 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Theme;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p4\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p4\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p4\DebugBar;
use YahnisElsts\PluginUpdateChecker\v5p7\UpdateChecker as BaseUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\InstalledPackage;
use YahnisElsts\PluginUpdateChecker\v5p7\Scheduler;
use YahnisElsts\PluginUpdateChecker\v5p7\DebugBar;
if ( !class_exists(UpdateChecker::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use stdClass;
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use stdClass;
use WP_Error;
@@ -171,6 +171,10 @@ if ( !class_exists(UpdateChecker::class, false) ):
//Allow HTTP requests to the metadata URL even if it's on a local host.
add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2);
//Potentially exclude information about this entity from core update check requests to api.wordpress.org.
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- Doesn't modify timeouts.
add_filter('http_request_args', array($this, 'excludeEntityFromWordPressAPI'), 10, 2);
//DebugBar integration.
if ( did_action('plugins_loaded') ) {
$this->maybeInitDebugBar();
@@ -192,6 +196,7 @@ if ( !class_exists(UpdateChecker::class, false) ):
remove_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10);
remove_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10);
remove_filter('http_request_args', array($this, 'excludeEntityFromWordPressAPI'));
remove_action('plugins_loaded', array($this, 'maybeInitDebugBar'));
remove_action('init', array($this, 'loadTextDomain'));
@@ -266,6 +271,76 @@ if ( !class_exists(UpdateChecker::class, false) ):
*/
abstract protected function createScheduler($checkPeriod);
/**
* Remove information about this plugin or theme from the requests that WordPress core sends
* to api.wordpress.org when checking for updates.
*
* @param array $args
* @param string $url
* @return array
*/
public function excludeEntityFromWordPressAPI($args, $url) {
//Is this an api.wordpress.org update check request?
$parsedUrl = wp_parse_url($url);
if ( !isset($parsedUrl['host']) || (strtolower($parsedUrl['host']) !== 'api.wordpress.org') ) {
return $args;
}
$typePluralised = $this->componentType . 's';
$expectedPathPrefix = '/' . $typePluralised . '/update-check/1.'; //e.g. "/plugins/update-check/1.1/"
if ( !isset($parsedUrl['path']) || !Utils::startsWith($parsedUrl['path'], $expectedPathPrefix) ) {
return $args;
}
//Plugins and themes can disable this feature by using the filter below.
if ( !apply_filters(
$this->getUniqueName('remove_from_default_update_checks'),
true, $this, $args, $url
) ) {
return $args;
}
if ( empty($args['body'][$typePluralised]) ) {
return $args;
}
$reportingItems = json_decode($args['body'][$typePluralised], true);
if ( $reportingItems === null ) {
return $args;
}
//The list of installed items uses different key formats for plugins and themes.
//Luckily, we can reuse the getUpdateListKey() method here.
$updateListKey = $this->getUpdateListKey();
if ( isset($reportingItems[$typePluralised][$updateListKey]) ) {
unset($reportingItems[$typePluralised][$updateListKey]);
}
if ( !empty($reportingItems['active']) ) {
if ( is_array($reportingItems['active']) ) {
foreach ($reportingItems['active'] as $index => $relativePath) {
if ( $relativePath === $updateListKey ) {
unset($reportingItems['active'][$index]);
}
}
//Re-index the array.
$reportingItems['active'] = array_values($reportingItems['active']);
} else if ( $reportingItems['active'] === $updateListKey ) {
//For themes, the "active" field is a string that contains the theme's directory name.
//Pretend that the default theme is active so that we don't reveal the actual theme.
if ( defined('WP_DEFAULT_THEME') ) {
$reportingItems['active'] = WP_DEFAULT_THEME;
}
//Unfortunately, it doesn't seem to be documented if we can safely remove the "active"
//key. So when we don't know the default theme, we'll just leave it as is.
}
}
$args['body'][$typePluralised] = wp_json_encode($reportingItems);
return $args;
}
/**
* Check for updates. The results are stored in the DB option specified in $optionName.
*
@@ -384,7 +459,7 @@ if ( !class_exists(UpdateChecker::class, false) ):
*
* @param Metadata|null $update
*/
protected function fixSupportedWordpressVersion(Metadata $update = null) {
protected function fixSupportedWordpressVersion($update = null) {
if ( !isset($update->tested) || !preg_match('/^\d++\.\d++$/', $update->tested) ) {
return;
}
@@ -557,7 +632,15 @@ if ( !class_exists(UpdateChecker::class, false) ):
if ( !empty($update) ) {
//Let plugins filter the update info before it's passed on to WordPress.
$update = apply_filters($this->getUniqueName('pre_inject_update'), $update);
$updates = $this->addUpdateToList($updates, $update->toWpFormat());
//Convert the update into the format used by WordPress core.
$wpUpdate = $update->toWpFormat();
//Disable the "autoupdate" flag unless explicitly allowed. This is a safety precaution;
//untrusted or compromised update sources could otherwise set this flag to true even
//if the plugin/theme developer didn't intend to allow automatic update installation.
if ( isset($wpUpdate->autoupdate) && !$this->isAutoupdateFieldAllowed($update) ) {
$wpUpdate->autoupdate = false;
}
$updates = $this->addUpdateToList($updates, $wpUpdate);
} else {
//Clean up any stale update info.
$updates = $this->removeUpdateFromList($updates);
@@ -655,6 +738,38 @@ if ( !class_exists(UpdateChecker::class, false) ):
return true;
}
/**
* @var bool
*/
protected $autoupdateFieldAllowed = false;
/**
* Allow the "autoupdate" field in incoming plugin updates to be set to `true`.
*
* By default, the update checker will parse the field (so it will be available in the update
* object), but will set it to `false` before passing the update to WordPress.
*
* @return $this
*/
public function allowAutoupdateField() {
$this->autoupdateFieldAllowed = true;
return $this;
}
/**
* Is the "autoupdate" field for injected updates allowed to be set to true?
*
* @param object $incomingUpdate
* @return bool
*/
protected function isAutoupdateFieldAllowed($incomingUpdate) {
return apply_filters(
$this->getUniqueName('autoupdate_field_allowed'),
$this->autoupdateFieldAllowed,
$incomingUpdate
);
}
/* -------------------------------------------------------------------
* JSON-based update API
* -------------------------------------------------------------------
@@ -924,25 +1039,62 @@ if ( !class_exists(UpdateChecker::class, false) ):
return $source;
}
//Fix the remote source structure if necessary.
//The update archive should contain a single directory that contains the rest of plugin/theme files.
//Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource).
//We can't rename $remoteSource because that would break WordPress code that cleans up temporary files
//after update.
if ( $this->isBadDirectoryStructure($remoteSource) ) {
//Create a new directory using the plugin slug.
$newDirectory = trailingslashit($remoteSource) . $this->slug . '/';
if ( !$wp_filesystem->is_dir($newDirectory) ) {
$wp_filesystem->mkdir($newDirectory);
//Move all files to the newly created directory.
$sourceFiles = $wp_filesystem->dirlist($remoteSource);
if ( is_array($sourceFiles) ) {
$sourceFiles = array_keys($sourceFiles);
$allMoved = true;
foreach ($sourceFiles as $filename) {
//Skip our newly created folder.
if ( $filename === $this->slug ) {
continue;
}
$previousSource = trailingslashit($remoteSource) . $filename;
$newSource = trailingslashit($newDirectory) . $filename;
if ( !$wp_filesystem->move($previousSource, $newSource, true) ) {
$allMoved = false;
break;
}
}
if ( $allMoved ) {
//Rename source.
$source = $newDirectory;
} else {
//Delete our newly created folder including all files in it.
$wp_filesystem->rmdir($newDirectory, true);
//And return a relevant error.
return new WP_Error(
'puc-incorrect-directory-structure',
sprintf(
'The directory structure of the update was incorrect. All files should be inside ' .
'a directory named <span class="code">%s</span>, not at the root of the ZIP archive. Plugin Update Checker tried to fix the directory structure, but failed.',
esc_html($this->slug)
)
);
}
}
}
}
//Rename the source to match the existing directory.
$correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/';
if ( $source !== $correctedSource ) {
//The update archive should contain a single directory that contains the rest of plugin/theme files.
//Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource).
//We can't rename $remoteSource because that would break WordPress code that cleans up temporary files
//after update.
if ( $this->isBadDirectoryStructure($remoteSource) ) {
return new WP_Error(
'puc-incorrect-directory-structure',
sprintf(
'The directory structure of the update is incorrect. All files should be inside ' .
'a directory named <span class="code">%s</span>, not at the root of the ZIP archive.',
htmlentities($this->slug)
)
);
}
/** @var \WP_Upgrader_Skin $upgrader ->skin */
$upgrader->skin->feedback(sprintf(
'Renaming %s to %s&#8230;',
'<span class="code">' . basename($source) . '</span>',
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(UpgraderStatus::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
if ( !class_exists(Utils::class, false) ):
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use Parsedown;
use PucReadmeParser;
@@ -51,6 +51,16 @@ if ( !class_exists(Api::class, false) ):
*/
protected $credentials = null;
/**
* @var string|null The value of the "Authorization" header for API requests.
*/
private $authorizationHeader = null;
/**
* @var string|null If set, add the "Authorization" header to update downloads that start with this prefix.
*/
private $downloadUrlPrefixRequiringAuth = null;
/**
* @var string The filter tag that's used to filter options passed to wp_remote_get.
* For example, "puc_request_info_options-slug" or "puc_request_update_options_theme-slug".
@@ -322,6 +332,22 @@ if ( !class_exists(Api::class, false) ):
return null;
}
/**
* @return array
*/
protected function getApiRequestHttpOptions() {
$options = ['timeout' => wp_doing_cron() ? 10 : 3];
if ( $this->isAuthenticationEnabled() && !empty($this->authorizationHeader) ) {
$options['headers'] = ['Authorization' => $this->authorizationHeader];
}
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
return $options;
}
/**
* Set authentication credentials.
*
@@ -332,7 +358,130 @@ if ( !class_exists(Api::class, false) ):
}
public function isAuthenticationEnabled() {
return !empty($this->credentials);
return !empty($this->credentials) || !empty($this->authorizationHeader);
}
/**
* Get the value of the "Authorization" header for API requests, if any.
*
* @return string
*/
protected function getAuthorizationHeader() {
return $this->authorizationHeader ?: '';
}
/**
* Enable basic access authentication with the specified username and password.
*
* @param string $username
* @param string $password
* @param string|null $downloadUrlPrefix Optionally, add the same Authorization header to update
* downloads where the download URL starts with this prefix.
*/
protected function enableBasicAuth($username, $password, $downloadUrlPrefix = null) {
$this->authorizationHeader = 'Basic ' . base64_encode($username . ':' . $password);
$this->downloadUrlPrefixRequiringAuth = $downloadUrlPrefix;
if ( !empty($this->downloadUrlPrefixRequiringAuth) ) {
$this->enableDownloadRequestFilter();
}
}
/**
* @var bool Whether the hook that registers download request filter(s) has already been added.
*/
private $preDownloadHookAdded = false;
/**
* Enable the hooks that let you filter HTTP requests for update downloads.
*/
protected function enableDownloadRequestFilter() {
if ( $this->preDownloadHookAdded ) {
return;
}
$this->preDownloadHookAdded = true;
//Optimization: Instead of filtering all HTTP requests, let's do it only when
//WordPress is about to download an update. So this is a two-step process;
//the actual request arg filter is added in the following hook.
add_filter('upgrader_pre_download', [$this, 'addDownloadRequestFilters']); //WP 3.7+
}
/**
* @var bool
*/
private $downloadFiltersAdded = false;
/**
* @internal
* @param bool $result Pass-through value for the "upgrader_pre_download" filter. Ignored by this callback.
* @return bool
*/
public function addDownloadRequestFilters($result) {
if ( !$this->downloadFiltersAdded ) {
$this->downloadFiltersAdded = true;
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
add_filter('http_request_args', [$this, 'filterUpdateDownloadRequestArgs'], 10, 2);
$authorizationHeader = $this->getAuthorizationHeader();
if ( $this->isAuthenticationEnabled() && !empty($authorizationHeader) ) {
add_action('requests-requests.before_redirect', [$this, 'removeAuthHeaderFromRedirects'], 10, 2);
}
}
return $result;
}
/**
* Filter request arguments/options for update downloads.
*
* Note that this callback can potentially be called for *any* update download. You still
* need to verify that the URL is one of yours.
*
* @internal
* @param array $requestArgs
* @param string $url
* @return array
*/
public function filterUpdateDownloadRequestArgs($requestArgs, $url = '') {
//Add an authorization header to our downloads if needed.
$authHeader = $this->getAuthorizationHeader();
if (
$this->isAuthenticationEnabled()
&& !empty($authHeader)
&& !empty($this->downloadUrlPrefixRequiringAuth)
&& ((strpos($url, $this->downloadUrlPrefixRequiringAuth)) === 0)
) {
$requestArgs['headers']['Authorization'] = $authHeader;
}
return $requestArgs;
}
/**
* At least in older WP versions, when following a redirect, the Requests library will
* automatically forward the Authorization header to other hosts. We don't want that
* because it breaks AWS downloads and can leak authorization information.
*
* @param string $location
* @param array $headers
* @internal
*/
public function removeAuthHeaderFromRedirects(&$location, &$headers) {
if (
//If there's no download URL prefix configured, we would not have added an auth header,
//and there's also no way to check if this URL needs auth or not.
empty($this->downloadUrlPrefixRequiringAuth)
//If this request goes to our download URL, we can leave the header.
|| ((strpos($location, $this->downloadUrlPrefixRequiringAuth)) === 0)
) {
return;
}
//Remove the header.
$authorizationHeader = $this->getAuthorizationHeader();
if ( isset($headers['Authorization']) && ($headers['Authorization'] === $authorizationHeader) ) {
unset($headers['Authorization']);
}
}
/**
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !interface_exists(BaseChecker::class, false) ):
@@ -1,18 +1,12 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p4\OAuthSignature;
use YahnisElsts\PluginUpdateChecker\v5p4\Utils;
use YahnisElsts\PluginUpdateChecker\v5p7\Utils;
if ( !class_exists(BitBucketApi::class, false) ):
class BitBucketApi extends Api {
/**
* @var OAuthSignature
*/
private $oauth = null;
/**
* @var string
*/
@@ -148,11 +142,19 @@ if ( !class_exists(BitBucketApi::class, false) ):
* @return string
*/
protected function getDownloadUrl($ref) {
return $this->getDownloadBaseUrl() . $ref . '.zip';
}
/**
* Get the base URL for ZIP downloads from our repo. Includes the trailing slash.
*
* @return string
*/
protected function getDownloadBaseUrl() {
return sprintf(
'https://bitbucket.org/%s/%s/get/%s.zip',
'https://bitbucket.org/%s/%s/get/',
$this->username,
$this->repository,
$ref
$this->repository
);
}
@@ -206,15 +208,7 @@ if ( !class_exists(BitBucketApi::class, false) ):
));
$baseUrl = $url;
if ( $this->oauth ) {
$url = $this->oauth->sign($url,'GET');
}
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
$response = wp_remote_get($url, $options);
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
@@ -248,25 +242,18 @@ if ( !class_exists(BitBucketApi::class, false) ):
public function setAuthentication($credentials) {
parent::setAuthentication($credentials);
if ( !empty($credentials) && !empty($credentials['consumer_key']) ) {
$this->oauth = new OAuthSignature(
$credentials['consumer_key'],
$credentials['consumer_secret']
if (
is_array($credentials)
&& !empty($credentials['username'])
&& !empty($credentials['api_token'])
) {
$this->enableBasicAuth(
$credentials['username'],
$credentials['api_token'],
$this->getDownloadBaseUrl()
);
} else {
$this->oauth = null;
}
}
public function signDownloadUrl($url) {
//Add authentication data to download URLs. Since OAuth signatures incorporate
//timestamps, we have to do this immediately before inserting the update. Otherwise,
//authentication could fail due to a stale timestamp.
if ( $this->oauth ) {
$url = $this->oauth->sign($url);
}
return $url;
}
}
endif;
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use Parsedown;
@@ -29,11 +29,6 @@ if ( !class_exists(GitHubApi::class, false) ):
*/
protected $accessToken;
/**
* @var bool
*/
private $downloadFilterAdded = false;
public function __construct($repositoryUrl, $accessToken = null) {
$path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
@@ -248,15 +243,7 @@ if ( !class_exists(GitHubApi::class, false) ):
$baseUrl = $url;
$url = $this->buildApiUrl($url, $queryParams);
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
if ( $this->isAuthenticationEnabled() ) {
$options['headers'] = array('Authorization' => $this->getAuthorizationHeader());
}
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
$response = wp_remote_get($url, $options);
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
@@ -348,11 +335,17 @@ if ( !class_exists(GitHubApi::class, false) ):
public function setAuthentication($credentials) {
parent::setAuthentication($credentials);
//This property is no longer used internally, but is kept for backwards compatibility.
$this->accessToken = is_string($credentials) ? $credentials : null;
//Optimization: Instead of filtering all HTTP requests, let's do it only when
//WordPress is about to download an update.
add_filter('upgrader_pre_download', array($this, 'addHttpRequestFilter'), 10, 1); //WP 3.7+
if ( is_string($credentials) && !empty($credentials) ) {
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', []);
$this->enableBasicAuth($this->userName, $credentials, $repoApiBaseUrl);
}
//Assets sometimes need an Accept header, so we add a request filter even if basic
//authentication isn't enabled.
$this->enableDownloadRequestFilter();
}
protected function getUpdateDetectionStrategies($configBranch) {
@@ -393,74 +386,15 @@ if ( !class_exists(GitHubApi::class, false) ):
return null;
}
/**
* @param bool $result
* @return bool
* @internal
*/
public function addHttpRequestFilter($result) {
if ( !$this->downloadFilterAdded && $this->isAuthenticationEnabled() ) {
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
add_filter('http_request_args', array($this, 'setUpdateDownloadHeaders'), 10, 2);
add_action('requests-requests.before_redirect', array($this, 'removeAuthHeaderFromRedirects'), 10, 4);
$this->downloadFilterAdded = true;
}
return $result;
}
/**
* Set the HTTP headers that are necessary to download updates from private repositories.
*
* See GitHub docs:
*
* @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
* @link https://developer.github.com/v3/auth/#basic-authentication
*
* @internal
* @param array $requestArgs
* @param string $url
* @return array
*/
public function setUpdateDownloadHeaders($requestArgs, $url = '') {
public function filterUpdateDownloadRequestArgs($requestArgs, $url = '') {
//Release assets need an "Accept" header. See GitHub Docs:
//https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
//Is WordPress trying to download one of our release assets?
if ( $this->releaseAssetsEnabled && (strpos($url, $this->getAssetApiBaseUrl()) !== false) ) {
$requestArgs['headers']['Accept'] = 'application/octet-stream';
}
//Use Basic authentication, but only if the download is from our repository.
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
if ( $this->isAuthenticationEnabled() && (strpos($url, $repoApiBaseUrl)) === 0 ) {
$requestArgs['headers']['Authorization'] = $this->getAuthorizationHeader();
}
return $requestArgs;
}
/**
* When following a redirect, the Requests library will automatically forward
* the authorization header to other hosts. We don't want that because it breaks
* AWS downloads and can leak authorization information.
*
* @param string $location
* @param array $headers
* @internal
*/
public function removeAuthHeaderFromRedirects(&$location, &$headers) {
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
if ( strpos($location, $repoApiBaseUrl) === 0 ) {
return; //This request is going to GitHub, so it's fine.
}
//Remove the header.
if ( isset($headers['Authorization']) ) {
unset($headers['Authorization']);
}
}
/**
* Generate the value of the "Authorization" header.
*
* @return string
*/
protected function getAuthorizationHeader() {
return 'Basic ' . base64_encode($this->userName . ':' . $this->accessToken);
return parent::filterUpdateDownloadRequestArgs($requestArgs, $url);
}
}
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !class_exists(GitLabApi::class, false) ):
@@ -260,12 +260,7 @@ if ( !class_exists(GitLabApi::class, false) ):
$baseUrl = $url;
$url = $this->buildApiUrl($url, $queryParams);
$options = array('timeout' => wp_doing_cron() ? 10 : 3);
if ( !empty($this->httpFilterName) ) {
$options = apply_filters($this->httpFilterName, $options);
}
$response = wp_remote_get($url, $options);
$response = wp_remote_get($url, $this->getApiRequestHttpOptions());
if ( is_wp_error($response) ) {
do_action('puc_api_error', $response, null, $url, $this->slug);
return $response;
@@ -1,8 +1,8 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p4\Plugin;
use YahnisElsts\PluginUpdateChecker\v5p7\Plugin;
if ( !class_exists(PluginUpdateChecker::class, false) ):
@@ -1,5 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !class_exists(Reference::class, false) ):
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !trait_exists(ReleaseAssetSupport::class, false) ) :
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !trait_exists(ReleaseFilteringFeature::class, false) ) :
@@ -1,9 +1,9 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
use YahnisElsts\PluginUpdateChecker\v5p4\Theme;
use YahnisElsts\PluginUpdateChecker\v5p4\Utils;
use YahnisElsts\PluginUpdateChecker\v5p7\Theme;
use YahnisElsts\PluginUpdateChecker\v5p7\Utils;
if ( !class_exists(ThemeUpdateChecker::class, false) ):
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4\Vcs;
namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs;
if ( !trait_exists(VcsCheckerMethods::class, false) ) :
@@ -1,6 +1,6 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use WP_CLI;
+23 -20
View File
@@ -29,7 +29,7 @@ From the users' perspective, it works just like with plugins and themes hosted o
Getting Started
---------------
*Note:* In each of the below examples, part of the instructions are to create an instance of the update checker class. It's recommended to do this either during the `plugins_loaded` action or outside of any hooks. If you do it only during an `admin_*` action, then updates will not be visible to a wide variety of WordPress maanagement tools; they will only be visible to logged-in users on dashboard pages.
*Note:* In each of the below examples, part of the instructions is to create an instance of the update checker class. It's recommended to do this either during the `plugins_loaded` action or outside of any hooks. If you do it only during an `admin_*` action, then updates will not be visible to a wide variety of WordPress management tools; they will only be visible to logged-in users on dashboard pages.
### Self-hosted Plugins and Themes
@@ -128,6 +128,11 @@ This library supports a couple of different ways to release updates on GitHub. P
$myUpdateChecker->getVcsApi()->enableReleaseAssets();
```
By default, PUC will simply use the first available asset. You can pass a regular expression to `enableReleaseAssets()` to filter assets by name. For example:
```php
$myUpdateChecker->getVcsApi()->enableReleaseAssets('/\.zip($|[?&#])/i');
```
- **Tags**
To release version 1.2.3, create a new Git tag named `v1.2.3` or `1.2.3`. That's it.
@@ -188,14 +193,12 @@ The library will pull update details from the following parts of a release/tag/b
'unique-plugin-or-theme-slug'
);
//Optional: If you're using a private repository, create an OAuth consumer
//and set the authentication credentials like this:
//Note: For now you need to check "This is a private consumer" when
//creating the consumer to work around #134:
// https://github.com/YahnisElsts/plugin-update-checker/issues/134
//Optional: If you're using a private repository, create an API token
//with the "read:repository:bitbucket" scope and set the authentication
//credentials like this:
$myUpdateChecker->setAuthentication(array(
'consumer_key' => '...',
'consumer_secret' => '...',
'username' => 'example@example.com', //Your BitBucket email address.
'api_token' => '...',
));
//Optional: Set the branch that contains the stable release.
@@ -252,8 +255,8 @@ BitBucket doesn't have an equivalent to GitHub's releases, so the process is sli
Alternatively, if you're using a self-hosted GitLab instance, initialize the update checker like this:
```php
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitLabApi;
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\PluginUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitLabApi;
$myUpdateChecker = new PluginUpdateChecker(
new GitLabApi('https://myserver.com/user-name/repo-name/'),
@@ -264,8 +267,8 @@ BitBucket doesn't have an equivalent to GitHub's releases, so the process is sli
```
If you're using a self-hosted GitLab instance and [subgroups or nested groups](https://docs.gitlab.com/ce/user/group/subgroups/index.html), you have to tell the update checker which parts of the URL are subgroups:
```php
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitLabApi;
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\PluginUpdateChecker;
use YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitLabApi;
$myUpdateChecker = new PluginUpdateChecker(
new GitLabApi(
@@ -347,14 +350,14 @@ Other classes have also been renamed, usually by simply removing the `Puc_vXpY_`
| Old class name | New class name |
|-------------------------------------|----------------------------------------------------------------|
| `Puc_v4_Factory` | `YahnisElsts\PluginUpdateChecker\v5\PucFactory` |
| `Puc_v4p13_Factory` | `YahnisElsts\PluginUpdateChecker\v5p4\PucFactory` |
| `Puc_v4p13_Plugin_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Plugin\UpdateChecker` |
| `Puc_v4p13_Theme_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Theme\UpdateChecker` |
| `Puc_v4p13_Vcs_PluginUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker` |
| `Puc_v4p13_Vcs_ThemeUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\ThemeUpdateChecker` |
| `Puc_v4p13_Vcs_GitHubApi` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitHubApi` |
| `Puc_v4p13_Vcs_GitLabApi` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\GitLabApi` |
| `Puc_v4p13_Vcs_BitBucketApi` | `YahnisElsts\PluginUpdateChecker\v5p4\Vcs\BitBucketApi` |
| `Puc_v4p13_Factory` | `YahnisElsts\PluginUpdateChecker\v5p7\PucFactory` |
| `Puc_v4p13_Plugin_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Plugin\UpdateChecker` |
| `Puc_v4p13_Theme_UpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Theme\UpdateChecker` |
| `Puc_v4p13_Vcs_PluginUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\PluginUpdateChecker` |
| `Puc_v4p13_Vcs_ThemeUpdateChecker` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\ThemeUpdateChecker` |
| `Puc_v4p13_Vcs_GitHubApi` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitHubApi` |
| `Puc_v4p13_Vcs_GitLabApi` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\GitLabApi` |
| `Puc_v4p13_Vcs_BitBucketApi` | `YahnisElsts\PluginUpdateChecker\v5p7\Vcs\BitBucketApi` |
License Management
------------------
+1 -1
View File
@@ -18,6 +18,6 @@
"ext-json": "*"
},
"autoload": {
"files": ["load-v5p4.php"]
"files": ["load-v5p7.php"]
}
}
@@ -0,0 +1,52 @@
msgid ""
msgstr ""
"Project-Id-Version: plugin-update-checker\n"
"POT-Creation-Date: 2022-07-29 15:34+0300\n"
"PO-Revision-Date: 2024-05-09 22:22+0000\n"
"Last-Translator: theogk\n"
"Language-Team: Ελληνικά\n"
"Language: el\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Loco https://localise.biz/\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
"X-Poedit-SearchPath-0: .\n"
"Report-Msgid-Bugs-To: \n"
"X-Loco-Version: 2.6.9; wp-6.5.3"
#: Puc/v5p4/Plugin/Ui.php:128
msgid "Check for updates"
msgstr "Έλεγχος για ενημερώσεις"
#: Puc/v5p4/Plugin/Ui.php:214
#, php-format
msgctxt "the plugin title"
msgid "The %s plugin is up to date."
msgstr "Το πρόσθετο %s είναι ενημερωμένο."
#: Puc/v5p4/Plugin/Ui.php:216
#, php-format
msgctxt "the plugin title"
msgid "A new version of the %s plugin is available."
msgstr "Μία νέα έκδοση είναι διαθέσιμη για το πρόσθετο %s."
#: Puc/v5p4/Plugin/Ui.php:218
#, php-format
msgctxt "the plugin title"
msgid "Could not determine if updates are available for %s."
msgstr ""
"Δεν ήταν εφικτό να εκτελεστεί ο έλεγχος για νέες ενημερώσεις για το πρόσθετο "
"%s."
#: Puc/v5p4/Plugin/Ui.php:224
#, php-format
msgid "Unknown update checker status \"%s\""
msgstr "Άγνωστο πρόβλημα του ενημερωτή προσθέτων \"%s\""
#: Puc/v5p4/Vcs/PluginUpdateChecker.php:100
msgid "There is no changelog available."
msgstr "Δεν υπάρχει διαθέσιμο αρχείο αλλαγών."
@@ -1,38 +1,50 @@
msgid ""
msgstr ""
"Project-Id-Version: plugin-update-checker\n"
"POT-Creation-Date: 2016-02-17 14:21+0100\n"
"PO-Revision-Date: 2016-10-28 14:30+0330\n"
"Last-Translator: studio RVOLA <hello@rvola.com>\n"
"Language-Team: Pro Style <info@prostyle.ir>\n"
"POT-Creation-Date: 2025-06-12 23:40+0100\n"
"PO-Revision-Date: 2025-06-12 23:49+0100\n"
"Last-Translator: Pro Style <info@prostyle.ir>\n"
"Language-Team: Alex Javadi <alex@aljm.org>\n"
"Language: fa_IR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.8.8\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Poedit 3.6\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: __;_e\n"
"X-Poedit-SearchPath-0: .\n"
#: github-checker.php:120
msgid "There is no changelog available."
msgstr "شرحی برای تغییرات یافت نشد"
#: Puc/v5p6/Plugin/Ui.php:56
msgid "View details"
msgstr "مشاهده جزئیات"
#: plugin-update-checker.php:637
#: Puc/v5p6/Plugin/Ui.php:79
#, php-format
msgid "More information about %s"
msgstr "اطلاعات بیشتر درباره %s"
# It had some “potential” grammar issues and also didnt sound native.
# P.S. I know the current translation is literally “Checking for new updates”, however, I thought it might sound more natural and known to others.
#: Puc/v5p6/Plugin/Ui.php:130
msgid "Check for updates"
msgstr "بررسی برای بروزرسانی "
msgstr "بررسی بروزرسانی جدید"
#: plugin-update-checker.php:681
msgid "This plugin is up to date."
msgstr "شما از آخرین نسخه استفاده میکنید . به‌روز باشید"
#: plugin-update-checker.php:683
msgid "A new version of this plugin is available."
msgstr "نسخه جدیدی برای افزونه ارائه شده است ."
#: plugin-update-checker.php:685
# The word “ناشناخته” is seems to be translated directly from the word (Un-known), rather than checking for the context.
# I think “نامشخص” (unknown) might be a suitable version in this scenario.
#: Puc/v5p6/Plugin/Ui.php:227
#, php-format
msgid "Unknown update checker status \"%s\""
msgstr "وضعیت ناشناخته برای بروزرسانی \"%s\""
msgstr "وضعیت نامشخص برای بروزرسانی \"%s\""
# The previous translation was okay, however, it didnt sound native to me.
#: Puc/v5p6/Vcs/PluginUpdateChecker.php:113
msgid "There is no changelog available."
msgstr "آخرین تغییراتی یافت نشد."
#~ msgid "This plugin is up to date."
#~ msgstr "شما از آخرین نسخه استفاده میکنید . به‌روز باشید"
#~ msgid "A new version of this plugin is available."
#~ msgstr "نسخه جدیدی برای افزونه ارائه شده است ."
@@ -0,0 +1,57 @@
msgid ""
msgstr ""
"Project-Id-Version: plugin-update-checker\n"
"POT-Creation-Date: 2024-12-09 11:45+0100\n"
"PO-Revision-Date: 2024-12-09 12:20+0100\n"
"Last-Translator: Aleksandar Urošević <urke.kg@gmail.com>\n"
"Language-Team: \n"
"Language: sr_RS\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.4.3\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
"X-Poedit-SearchPath-0: .\n"
#: Puc/v5p5/Plugin/Ui.php:56
msgid "View details"
msgstr "Види детаље"
#: Puc/v5p5/Plugin/Ui.php:79
#, php-format
msgid "More information about %s"
msgstr "Више информација о %s"
#: Puc/v5p5/Plugin/Ui.php:130
msgid "Check for updates"
msgstr "Провера ажурирања"
#: Puc/v5p5/Plugin/Ui.php:217
#, php-format
msgctxt "the plugin title"
msgid "The %s plugin is up to date."
msgstr "Додатак %s је у најновијем издању."
#: Puc/v5p5/Plugin/Ui.php:219
#, php-format
msgctxt "the plugin title"
msgid "A new version of the %s plugin is available."
msgstr "Доступно је ново издање за %s."
#: Puc/v5p5/Plugin/Ui.php:221
#, php-format
msgctxt "the plugin title"
msgid "Could not determine if updates are available for %s."
msgstr "Није могуће утврдити да ли су доступне исправке за %s."
#: Puc/v5p5/Plugin/Ui.php:227
#, php-format
msgid "Unknown update checker status \"%s\""
msgstr "Непознат статус провере ажурирања \"%s\""
#: Puc/v5p5/Vcs/PluginUpdateChecker.php:113
msgid "There is no changelog available."
msgstr "Белешке о изменама нису доступне."
@@ -1,57 +1,57 @@
msgid ""
msgstr ""
"Project-Id-Version: plugin-update-checker\n"
"POT-Creation-Date: 2022-01-29 12:09+0800\n"
"PO-Revision-Date: 2022-01-29 12:10+0800\n"
"POT-Creation-Date: 2025-11-21 10:40+0800\n"
"PO-Revision-Date: 2025-11-21 10:40+0800\n"
"Last-Translator: Seaton Jiang <hi@seatonjiang.com>\n"
"Language-Team: \n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.3\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Poedit 3.8\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
"X-Poedit-SearchPath-0: .\n"
#: Puc/v4p11/Plugin/Ui.php:54
#: Puc/v5p6/Plugin/Ui.php:56
msgid "View details"
msgstr "查看详情"
#: Puc/v4p11/Plugin/Ui.php:77
#: Puc/v5p6/Plugin/Ui.php:79
#, php-format
msgid "More information about %s"
msgstr "%s 的更多信息"
#: Puc/v4p11/Plugin/Ui.php:128
#: Puc/v5p6/Plugin/Ui.php:130
msgid "Check for updates"
msgstr "检查更新"
#: Puc/v4p11/Plugin/Ui.php:214
#: Puc/v5p6/Plugin/Ui.php:217
#, php-format
msgctxt "the plugin title"
msgid "The %s plugin is up to date."
msgstr "%s 目前是最新版本。"
#: Puc/v4p11/Plugin/Ui.php:216
#: Puc/v5p6/Plugin/Ui.php:219
#, php-format
msgctxt "the plugin title"
msgid "A new version of the %s plugin is available."
msgstr "%s 当前有可用的更新。"
#: Puc/v4p11/Plugin/Ui.php:218
#: Puc/v5p6/Plugin/Ui.php:221
#, php-format
msgctxt "the plugin title"
msgid "Could not determine if updates are available for %s."
msgstr "%s 无法确定是否有可用的更新。"
#: Puc/v4p11/Plugin/Ui.php:224
#: Puc/v5p6/Plugin/Ui.php:227
#, php-format
msgid "Unknown update checker status \"%s\""
msgstr "未知的更新检查状态:%s"
#: Puc/v4p11/Vcs/PluginUpdateChecker.php:100
#: Puc/v5p6/Vcs/PluginUpdateChecker.php:113
msgid "There is no changelog available."
msgstr "没有可用的更新日志。"
@@ -0,0 +1,66 @@
# Blank Plugin POT Template
# Copyright 2025 ...
# This file is distributed under the GNU General Public License v3 or later.
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Plugin Update Checker\n"
"Report-Msgid-Bugs-To: Alex Lion <learnwithalex@gmail.com>\n"
"POT-Creation-Date: 2025-09-19 14:05-0700\n"
"PO-Revision-Date: \n"
"Last-Translator: Alex Lion <learnwithalex@gmail.com>\n"
"Language-Team: Alex Lion <learnwithalex@gmail.com>\n"
"Language: zh_TW\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Poedit-WPHeader: plugin-update-checker.php\n"
"X-Textdomain-Support: yesX-Generator: Poedit 1.6.4\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: "
"__;_e;esc_html_e;esc_html_x:1,2c;esc_html__;esc_attr_e;esc_attr_x:1,2c;esc_attr__;_ex:1,2c;_nx:4c,1,2;_nx_noop:4c,1,2;_x:1,2c;_n:1,2;_n_noop:1,2;__ngettext:1,2;__ngettext_noop:1,2;_c,_nc:4c,1,2\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-Bookmarks: \n"
"X-Generator: Poedit 3.7\n"
"X-Poedit-SearchPath-0: .\n"
#: Puc/v5p6/Plugin/Ui.php:56
msgid "View details"
msgstr "檢視詳細資料"
#: Puc/v5p6/Plugin/Ui.php:79
#, php-format
msgid "More information about %s"
msgstr "進一步了解 %s 的相關資訊"
#: Puc/v5p6/Plugin/Ui.php:130
msgid "Check for updates"
msgstr "檢查更新"
#: Puc/v5p6/Plugin/Ui.php:217
#, php-format
msgctxt "the plugin title"
msgid "The %s plugin is up to date."
msgstr "%s 外掛已為最新版本。"
#: Puc/v5p6/Plugin/Ui.php:219
#, php-format
msgctxt "the plugin title"
msgid "A new version of the %s plugin is available."
msgstr "%s 外掛已有新版本可供更新。"
#: Puc/v5p6/Plugin/Ui.php:221
#, php-format
msgctxt "the plugin title"
msgid "Could not determine if updates are available for %s."
msgstr "無法確定 %s 是否有可用的更新。"
#: Puc/v5p6/Plugin/Ui.php:227
#, php-format
msgid "Unknown update checker status \"%s\""
msgstr "未知的更新檢查程式狀態: %s"
#: Puc/v5p6/Vcs/PluginUpdateChecker.php:113
msgid "There is no changelog available."
msgstr "目前沒有可供檢閱的變更記錄。"
@@ -2,7 +2,7 @@
msgid ""
msgstr ""
"Project-Id-Version: plugin-update-checker\n"
"POT-Creation-Date: 2022-07-29 15:34+0300\n"
"POT-Creation-Date: 2025-05-20 15:27+0300\n"
"PO-Revision-Date: 2016-01-10 20:59+0100\n"
"Last-Translator: \n"
"Language-Team: \n"
@@ -11,39 +11,39 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.1.1\n"
"X-Generator: Poedit 3.6\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_x\n"
"X-Poedit-SearchPath-0: .\n"
#: Puc/v5p4/Plugin/Ui.php:128
#: Puc/v5p7/Plugin/Ui.php:130
msgid "Check for updates"
msgstr ""
#: Puc/v5p4/Plugin/Ui.php:214
#: Puc/v5p7/Plugin/Ui.php:217
#, php-format
msgctxt "the plugin title"
msgid "The %s plugin is up to date."
msgstr ""
#: Puc/v5p4/Plugin/Ui.php:216
#: Puc/v5p7/Plugin/Ui.php:219
#, php-format
msgctxt "the plugin title"
msgid "A new version of the %s plugin is available."
msgstr ""
#: Puc/v5p4/Plugin/Ui.php:218
#: Puc/v5p7/Plugin/Ui.php:221
#, php-format
msgctxt "the plugin title"
msgid "Could not determine if updates are available for %s."
msgstr ""
#: Puc/v5p4/Plugin/Ui.php:224
#: Puc/v5p7/Plugin/Ui.php:227
#, php-format
msgid "Unknown update checker status \"%s\""
msgstr ""
#: Puc/v5p4/Vcs/PluginUpdateChecker.php:100
#: Puc/v5p7/Vcs/PluginUpdateChecker.php:113
msgid "There is no changelog available."
msgstr ""
@@ -1,14 +1,14 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p4;
namespace YahnisElsts\PluginUpdateChecker\v5p7;
use YahnisElsts\PluginUpdateChecker\v5\PucFactory as MajorFactory;
use YahnisElsts\PluginUpdateChecker\v5p4\PucFactory as MinorFactory;
use YahnisElsts\PluginUpdateChecker\v5p7\PucFactory as MinorFactory;
require __DIR__ . '/Puc/v5p4/Autoloader.php';
require __DIR__ . '/Puc/v5p7/Autoloader.php';
new Autoloader();
require __DIR__ . '/Puc/v5p4/PucFactory.php';
require __DIR__ . '/Puc/v5p7/PucFactory.php';
require __DIR__ . '/Puc/v5/PucFactory.php';
//Register classes defined in this version with the factory.
@@ -26,9 +26,9 @@ foreach (
)
as $pucGeneralClass => $pucVersionedClass
) {
MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.4');
MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7');
//Also add it to the minor-version factory in case the major-version factory
//was already defined by another, older version of the update checker.
MinorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.4');
MinorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7');
}
@@ -1,10 +1,10 @@
<?php
/**
* Plugin Update Checker Library 5.4
* Plugin Update Checker Library 5.7
* http://w-shadow.com/
*
* Copyright 2024 Janis Elsts
* Copyright 2026 Janis Elsts
* Released under the MIT license. See license.txt for details.
*/
require dirname(__FILE__) . '/load-v5p4.php';
require dirname(__FILE__) . '/load-v5p7.php';
+2 -2
View File
@@ -648,7 +648,7 @@ class Parsedown
#
# Setext
protected function blockSetextHeader($Line, array $Block = null)
protected function blockSetextHeader($Line, $Block = null)
{
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
{
@@ -786,7 +786,7 @@ class Parsedown
#
# Table
protected function blockTable($Line, array $Block = null)
protected function blockTable($Line, $Block = null)
{
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
{
+235
View File
@@ -0,0 +1,235 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Gitea adapter for the Repository_Provider interface.
*
* Endpoint base: {base_url}/api/v1
* - repo: {base_url}/api/v1/repos/{owner}/{repo}
* - release: {base_url}/api/v1/repos/{owner}/{repo}/releases/latest (returns array)
* - user: {base_url}/api/v1/users/{login}
* - archive: {base_url}/{owner}/{repo}/archive/refs/tags/{tag}.zip
*
* @since 1.1.0
*/
class Gitea_Provider implements Repository_Provider {
private string $repo_url;
private string $base_url;
private string $api_token;
public function __construct( string $repo_url, string $base_url, string $api_token = '' ) {
$this->repo_url = $repo_url;
$this->base_url = rtrim( $base_url, '/' );
$this->api_token = $api_token;
}
public function get_id(): string {
return 'gitea';
}
public function get_label(): string {
return 'Gitea';
}
public function get_repo_data(): ?array {
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path();
if ( empty( $api_url ) ) {
error_log( 'Projects Portfolio: Gitea repo URL is empty.' );
return null;
}
$cache_key = 'projects_portfolio_provider_data_' . md5( $api_url );
$cached_data = get_transient( $cache_key );
if ( $cached_data ) {
return $cached_data;
}
$headers = [ 'Accept' => 'application/json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
} else {
error_log( 'Projects Portfolio: Gitea API token is missing. Using unauthenticated requests.' );
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: Gitea API error: Received status ' . $code );
return null;
}
$raw = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $raw ) || ! is_array( $raw ) ) {
error_log( 'Projects Portfolio: Gitea API error: Invalid data received.' );
return null;
}
$owner = $raw['owner'] ?? [];
$owner_login = $owner['login'] ?? $this->owner_login_from_path();
$data = [
'owner' => [
'avatar_url' => $owner['avatar_url'] ?? '',
'login' => $owner_login,
'html_url' => $owner['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
],
'updated_at' => $raw['updated_at'] ?? '',
'language' => $raw['language'] ?? '',
'license' => $this->normalize_license( $raw['license'] ?? null ),
'stargazers_count' => (int) ( $raw['stars_count'] ?? 0 ),
'forks_count' => (int) ( $raw['forks_count'] ?? 0 ),
'open_issues_count' => (int) ( $raw['open_issues_count'] ?? 0 ),
];
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return $data;
}
public function get_release_url(): ?string {
$release = $this->fetch_latest_release();
if ( null === $release ) {
return null;
}
$assets = $release['assets'] ?? [];
if ( is_array( $assets ) ) {
foreach ( $assets as $asset ) {
if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) {
$url = $asset['browser_download_url'] ?? null;
if ( $url ) {
return $url;
}
}
}
}
$tag = $release['tag_name'] ?? '';
if ( '' === $tag ) {
return null;
}
return $this->base_url . '/' . $this->owner_repo_path() . '/archive/refs/tags/' . $tag . '.zip';
}
public function get_latest_version(): string {
$release = $this->fetch_latest_release();
if ( null === $release ) {
return 'Unknown';
}
return isset( $release['tag_name'] ) ? (string) $release['tag_name'] : 'Unknown';
}
public function get_repo_browse_url(): string {
return $this->base_url . '/' . $this->owner_repo_path();
}
public function get_owner_data( string $owner_login ): ?array {
if ( empty( $owner_login ) ) {
return null;
}
$api_url = $this->base_url . '/api/v1/users/' . rawurlencode( $owner_login );
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return null;
}
return [
'avatar_url' => $data['avatar_url'] ?? '',
'login' => $data['login'] ?? $owner_login,
'html_url' => $data['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
];
}
/**
* Returns the first release object or null on failure / empty response.
* Gitea's /releases/latest returns an array.
*/
private function fetch_latest_release(): ?array {
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path() . '/releases/latest';
$cache_key = 'projects_portfolio_provider_release_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: Gitea releases/latest returned status ' . $code );
return null;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body ) || empty( $body ) ) {
return null;
}
// 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 );
return $release;
}
private function auth_headers(): array {
$headers = [ 'Accept' => 'application/json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
}
return $headers;
}
private function owner_repo_path(): string {
// Strip the base URL prefix if present, return "owner/repo".
$path = $this->repo_url;
if ( '' !== $this->base_url && 0 === strpos( $path, $this->base_url ) ) {
$path = substr( $path, strlen( $this->base_url ) );
}
$path = ltrim( $path, '/' );
// Drop trailing slashes and any "/archive/...", "/releases/...", etc.
$path = preg_replace( '#/(archive|releases|tree|blob|issues|pulls|src|commits|wiki)(/.*)?$#', '', $path );
return trim( $path, '/' );
}
private function owner_login_from_path(): string {
$parts = explode( '/', $this->owner_repo_path() );
return $parts[0] ?? '';
}
private function normalize_license( $license ): array {
if ( is_array( $license ) ) {
$name = $license['name'] ?? '';
return [ 'name' => '' !== $name ? $name : 'None' ];
}
if ( is_string( $license ) && '' !== $license ) {
return [ 'name' => $license ];
}
return [ 'name' => 'None' ];
}
}
@@ -0,0 +1,195 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* GitHub adapter for the Repository_Provider interface.
*
* Preserves the GitHub URL/header/asset behavior from the original
* helper-functions.php verbatim.
*
* @since 1.1.0
*/
class GitHub_Provider implements Repository_Provider {
private string $repo_url;
private string $api_token;
public function __construct( string $repo_url, string $api_token = '' ) {
$this->repo_url = rtrim( $repo_url, '/' );
$this->api_token = $api_token;
}
public function get_id(): string {
return 'github';
}
public function get_label(): string {
return 'GitHub';
}
public function get_repo_data(): ?array {
if ( empty( $this->repo_url ) ) {
error_log( 'Projects Portfolio: GitHub URL is empty.' );
return null;
}
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url );
$cache_key = 'projects_portfolio_provider_data_' . md5( $api_url );
$cached_data = get_transient( $cache_key );
if ( $cached_data ) {
return $cached_data;
}
$headers = [ 'Accept' => 'application/vnd.github.v3+json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
} else {
error_log( 'Projects Portfolio: GitHub API token is missing. Using unauthenticated requests.' );
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: GitHub API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 403 === $code ) {
$remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' );
$reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
error_log( 'Projects Portfolio: GitHub API 403: Rate limit exceeded. Remaining: ' . $remaining . ' Reset at: ' . date( 'Y-m-d H:i:s', (int) $reset ) );
return null;
}
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: GitHub API error: Received status ' . $code );
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $data ) || ! is_array( $data ) ) {
error_log( 'Projects Portfolio: GitHub API error: Invalid data received.' );
return null;
}
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return $data;
}
public function get_release_url(): ?string {
$api_url = $this->release_api_url();
if ( null === $api_url ) {
return null;
}
$cache_key = 'projects_portfolio_provider_release_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$headers = [ 'Accept' => 'application/vnd.github.v3+json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return null;
}
if ( ! empty( $data['assets'] ) && is_array( $data['assets'] ) ) {
foreach ( $data['assets'] as $asset ) {
if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) {
$url = $asset['browser_download_url'] ?? null;
if ( $url ) {
set_transient( $cache_key, $url, HOUR_IN_SECONDS );
return $url;
}
}
}
}
$zipball = $data['zipball_url'] ?? null;
if ( $zipball ) {
set_transient( $cache_key, $zipball, HOUR_IN_SECONDS );
}
return $zipball;
}
public function get_latest_version(): string {
$api_url = $this->release_api_url();
if ( null === $api_url ) {
return 'Unknown';
}
$cache_key = 'projects_portfolio_provider_release_version_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$response = wp_remote_get( $api_url, [] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return 'Unknown';
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$tag = is_array( $data ) && isset( $data['tag_name'] ) ? (string) $data['tag_name'] : 'Unknown';
set_transient( $cache_key, $tag, HOUR_IN_SECONDS );
return $tag;
}
public function get_repo_browse_url(): string {
return $this->repo_url;
}
public function get_owner_data( string $owner_login ): ?array {
if ( empty( $owner_login ) ) {
return null;
}
$api_url = 'https://api.github.com/users/' . $owner_login;
$ctx = stream_context_create( [
'http' => [
'method' => 'GET',
'header' => "User-Agent: WORDPRESS\r\n",
],
] );
$body = @file_get_contents( $api_url, false, $ctx );
if ( false === $body ) {
error_log( 'Projects Portfolio: Error fetching data from GitHub API.' );
return null;
}
$data = json_decode( $body, true );
if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
error_log( 'Projects Portfolio: Error decoding JSON data: ' . json_last_error_msg() );
return null;
}
return [
'avatar_url' => $data['avatar_url'] ?? '',
'login' => $data['login'] ?? $owner_login,
'html_url' => $data['html_url'] ?? ( 'https://github.com/' . $owner_login ),
];
}
private function release_api_url(): ?string {
if ( empty( $this->repo_url ) ) {
return null;
}
return str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url ) . '/releases/latest';
}
}
@@ -0,0 +1,36 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Provider factory: selects the right adapter for a project post.
*
* @since 1.1.0
*
* @param int $post_id
* @return Repository_Provider
*/
function projects_portfolio_get_provider( int $post_id ): Repository_Provider {
$provider_meta = (string) get_post_meta( $post_id, '_projects_portfolio_provider', true );
$provider_id = '' !== $provider_meta ? $provider_meta : 'github';
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
// Lazy fallback to legacy key.
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_github_url', true );
}
if ( 'gitea' === $provider_id ) {
$base_url = (string) get_post_meta( $post_id, '_projects_portfolio_gitea_base_url', true );
if ( '' === $base_url ) {
$base_url = (string) get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
}
$token = (string) get_option( 'projects_portfolio_gitea_api_token', '' );
return new Gitea_Provider( $repo_url, $base_url, $token );
}
$token = (string) get_option( 'projects_portfolio_github_api_token', '' );
return new GitHub_Provider( $repo_url, $token );
}
@@ -0,0 +1,47 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Contract for a repository host adapter (GitHub, Gitea, ...).
*
* @since 1.1.0
*/
interface Repository_Provider {
public function get_id(): string;
public function get_label(): string;
/**
* Normalized repo metadata. Returns null on any failure.
*
* Shape:
* [
* 'owner' => [ 'avatar_url' => string, 'login' => string, 'html_url' => string ],
* 'updated_at' => string (ISO 8601),
* 'language' => string,
* 'license' => [ 'name' => string ],
* 'stargazers_count' => int,
* 'forks_count' => int,
* 'open_issues_count' => int,
* ]
*/
public function get_repo_data(): ?array;
/** Direct URL to the latest release zip asset, or null. */
public function get_release_url(): ?string;
/** Tag name of the latest release, or 'Unknown'. */
public function get_latest_version(): string;
/** Public URL of the repo, for the 'View Repo' button. */
public function get_repo_browse_url(): string;
/**
* Owner profile data: [ 'avatar_url', 'login', 'html_url' ].
* Returns null on failure.
*/
public function get_owner_data( string $owner_login ): ?array;
}
@@ -1,3 +1,3 @@
<?php
// generated by Poedit from projects-portfolio-es_MX.po, do not edit directly
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'es_MX','pot-creation-date'=>'2025-04-04 10:12-0400','po-revision-date'=>'2025-04-04 10:13-0400','translation-revision-date'=>'2025-04-04 10:13-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Ajustes','Version'=>'Versión','Last Updated'=>'Última Actualización','License'=>'Licencia','Language'=>'Idioma','Downloads'=>'Descargas','Forks'=>'Horquillas','Stars'=>'Estrellas','Issues'=>'Problemas','GitHub Owner'=>'Propietario de GitHub','Archive Title'=>'Título del archivo','Project Title'=>'Título del Proyecto','Project Excerpt'=>'Estracto de proyecto','Project Buttons'=>'Botones de proyecto','Projects for WordPress Settings'=>'Proyectos para la configuración de WordPress','General Settings'=>'Ajustes Generales','GitHub API Token'=>'Token de la API de GitHub','Templates Settings'=>'Configuración de plantillas','Archives Settings'=>'Ajustes de los archivos','Save Settings'=>'Guardar Ajustes','Settings saved successfully.'=>'Ajustes guardados exitosamente.','Projects'=>'Proyectos','Project'=>'Proyecto','Add New Project'=>'Agregar Proyecto','Project Types'=>'Proyectos','Project Type'=>'Tipo de Proyecto','Plugin'=>'Plugin','Theme'=>'Tema','Pattern'=>'Patron','GitHub URL'=>'GitHub URL','GitHub Repository URL:'=>'URL del repositorio GitHub:','View details'=>'Ver Detalles','More information about %s'=>'Más información sobre %s','Check for updates'=>'Checar por Actualizaciones','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'El plugin %s está actualizado.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Una nueva versión del plugin %s está disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'No se pudo determinar si las actualizaciones están disponibles para %s.','Unknown update checker status "%s"'=>'Status de actualización desconocido "%s"','There is no changelog available.'=>'No está disponible la lista de cambios.','Invalid download URL. Please check the GitHub repository.'=>'URL de descarga no válida. Compruebe el repositorio de GitHub.','Download Error'=>'Error','Download Count'=>'Descargar el recuento','0'=>'0','Download'=>'Descargar','View Project'=>'Ver Proyecto','&laquo; Previous'=>'&laquo; Anterior','Next &raquo;'=>'Siguiente &raquo;','No projects found.'=>'No se han encontrado proyectos.','Project Banner'=>'Proyecto Banner','Download Now'=>'Descargar Ahora','View on GitHub'=>'Ver en GitHub','GitHub Repository:'=>'Repositorio GitHub:','No repository linked.'=>'Ningún repositorio vinculado.','Version:'=>'Versión:','Updated:'=>'Actualizado:','License:'=>'Licencia:','Language:'=>'Idioma:','Downloads:'=>'Descargas:','Stars:'=>'Estrellas:','Forks:'=>'Horquillas:','Issues:'=>'Problemas:','Follow'=>'Seguir','Projects for WordPress®'=>'Proyectos para WordPress','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Crear un directorio de escaparate para proyectos (plugins, temas, patrones) con tipos de post personalizados, taxonomías y funcionalidad de descarga.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'es_MX','pot-creation-date'=>'2025-04-04 10:12-0400','po-revision-date'=>'2025-04-04 10:13-0400','translation-revision-date'=>'2025-04-04 10:13-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Ajustes','Version'=>'Versión','Last Updated'=>'Última Actualización','License'=>'Licencia','Language'=>'Idioma','Downloads'=>'Descargas','Forks'=>'Horquillas','Stars'=>'Estrellas','Issues'=>'Problemas','GitHub Owner'=>'Propietario de GitHub','Archive Title'=>'Título del archivo','Project Title'=>'Título del Proyecto','Project Excerpt'=>'Estracto de proyecto','Project Buttons'=>'Botones de proyecto','Projects for WordPress Settings'=>'Proyectos para la configuración de WordPress','General Settings'=>'Ajustes Generales','GitHub API Token'=>'Token de la API de GitHub','Templates Settings'=>'Configuración de plantillas','Archives Settings'=>'Ajustes de los archivos','Save Settings'=>'Guardar Ajustes','Settings saved successfully.'=>'Ajustes guardados exitosamente.','Projects'=>'Proyectos','Project'=>'Proyecto','Add New Project'=>'Agregar Proyecto','Project Types'=>'Proyectos','Project Type'=>'Tipo de Proyecto','Plugin'=>'Plugin','Theme'=>'Tema','Pattern'=>'Patron','GitHub URL'=>'GitHub URL','GitHub Repository URL:'=>'URL del repositorio GitHub:','View details'=>'Ver Detalles','More information about %s'=>'Más información sobre %s','Check for updates'=>'Checar por Actualizaciones','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'El plugin %s está actualizado.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Una nueva versión del plugin %s está disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'No se pudo determinar si las actualizaciones están disponibles para %s.','Unknown update checker status "%s"'=>'Status de actualización desconocido "%s"','There is no changelog available.'=>'No está disponible la lista de cambios.','Invalid download URL. Please check the GitHub repository.'=>'URL de descarga no válida. Compruebe el repositorio de GitHub.','Invalid download URL. Please check the repository URL.'=>'URL de descarga no válida. Compruebe el repositorio.','Repository'=>'','Provider'=>'','Gitea Base URL (optional override)'=>'','Leave blank to use the default from Settings.'=>'','View Repo'=>'','Gitea Settings'=>'','Default Gitea Base URL'=>'','Used when a Gitea project has no per-project base URL override.'=>'','Gitea API Token'=>'','Download Error'=>'Error','Download Count'=>'Descargar el recuento','0'=>'0','Download'=>'Descargar','View Project'=>'Ver Proyecto','&laquo; Previous'=>'&laquo; Anterior','Next &raquo;'=>'Siguiente &raquo;','No projects found.'=>'No se han encontrado proyectos.','Project Banner'=>'Proyecto Banner','Download Now'=>'Descargar Ahora','View on GitHub'=>'Ver en GitHub','GitHub Repository:'=>'Repositorio GitHub:','No repository linked.'=>'Ningún repositorio vinculado.','Version:'=>'Versión:','Updated:'=>'Actualizado:','License:'=>'Licencia:','Language:'=>'Idioma:','Downloads:'=>'Descargas:','Stars:'=>'Estrellas:','Forks:'=>'Horquillas:','Issues:'=>'Problemas:','Follow'=>'Seguir','Projects for WordPress®'=>'Proyectos para WordPress','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Crear un directorio de escaparate para proyectos (plugins, temas, patrones) con tipos de post personalizados, taxonomías y funcionalidad de descarga.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
+40
View File
@@ -88,6 +88,42 @@ msgstr "Ajustes Generales"
msgid "GitHub API Token"
msgstr "Token de la API de GitHub"
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:86
msgid "Templates Settings"
msgstr "Configuración de plantillas"
@@ -189,6 +225,10 @@ msgstr "No está disponible la lista de cambios."
msgid "Invalid download URL. Please check the GitHub repository."
msgstr "URL de descarga no válida. Compruebe el repositorio de GitHub."
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr "URL de descarga no válida. Compruebe el repositorio."
#: projects-portfolio.php:118
msgid "Download Error"
msgstr "Error"
+1 -1
View File
@@ -1,3 +1,3 @@
<?php
// generated by Poedit from projects-wp-fr_FR.po, do not edit directly
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'fr_FR','pot-creation-date'=>'2025-04-04 10:13-0400','po-revision-date'=>'2025-04-04 10:14-0400','translation-revision-date'=>'2025-04-04 10:14-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Paramètres','Version'=>'Version','Last Updated'=>'Dernière mise à jour','License'=>'Licence','Language'=>'Langue','Downloads'=>'Téléchargements','Forks'=>'Fourchettes','Stars'=>'Étoiles','Issues'=>'Problèmes','GitHub Owner'=>'Propriétaire de GitHub','Archive Title'=>'Archive Titre','Project Title'=>'Titre du projet','Project Excerpt'=>'Extrait du projet','Project Buttons'=>'Boutons de projet','Projects for WordPress Settings'=>'Projets pour les réglages de WordPress','General Settings'=>'Réglages généraux','GitHub API Token'=>'Token API GitHub','Templates Settings'=>'¨Paramètres des modèles','Archives Settings'=>'Paramètres des archives','Save Settings'=>'Enregistrer les paramètres','Settings saved successfully.'=>'Réglages bien enregistrés.','Projects'=>'Projets','Project'=>'Projet','Add New Project'=>'Ajouter un nouveau projet','Project Types'=>'Types de projets','Project Type'=>'Type de projet','Plugin'=>'Extension','Theme'=>'Thème','Pattern'=>'Motif','GitHub URL'=>'URL GitHub','GitHub Repository URL:'=>'URL du dépôt GitHub :','View details'=>'Voir les détails','More information about %s'=>'Plus dinformations sur %s','Check for updates'=>'Vérifier les mises à jour','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'Le plugin %s est à jour.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Une nouvelle version de l\'extension %s est disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'Impossible de déterminer si des mises à jour sont disponibles pour %s.','Unknown update checker status "%s"'=>'Mise à jour inconnue statut "%s"','There is no changelog available.'=>'Aucun registre de modifications disponible.','Invalid download URL. Please check the GitHub repository.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub.','Download Error'=>'Télécharger Erreur','Download Count'=>'Nombre de Téléchargements','0'=>'0','Download'=>'Télécharger','View Project'=>'Voir le projet','&laquo; Previous'=>'&laquo; Précédent','Next &raquo;'=>'Suivant &raquo;','No projects found.'=>'Aucun projet trouvé.','Project Banner'=>'Bannière du projet','Download Now'=>'Télécharger maintenant','View on GitHub'=>'Voir sur GitHub','GitHub Repository:'=>'Dépôt GitHub:','No repository linked.'=>'Aucun dépôt n\'est lié.','Version:'=>'Version:','Updated:'=>'Mis à jour :','License:'=>'Licence:','Language:'=>'Langue:','Downloads:'=>'Téléchargements:','Stars:'=>'Étoiles:','Forks:'=>'Forks:','Issues:'=>'Problèmes:','Follow'=>'Suivre','Projects for WordPress®'=>'Projets pour WordPress®','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Créer un répertoire de présentation pour les projets (plugins, thèmes, modèles) avec des types d\'articles personnalisés, des taxonomies et des fonctionnalités de téléchargement.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'fr_FR','pot-creation-date'=>'2025-04-04 10:13-0400','po-revision-date'=>'2025-04-04 10:14-0400','translation-revision-date'=>'2025-04-04 10:14-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Paramètres','Version'=>'Version','Last Updated'=>'Dernière mise à jour','License'=>'Licence','Language'=>'Langue','Downloads'=>'Téléchargements','Forks'=>'Fourchettes','Stars'=>'Étoiles','Issues'=>'Problèmes','GitHub Owner'=>'Propriétaire de GitHub','Archive Title'=>'Archive Titre','Project Title'=>'Titre du projet','Project Excerpt'=>'Extrait du projet','Project Buttons'=>'Boutons de projet','Projects for WordPress Settings'=>'Projets pour les réglages de WordPress','General Settings'=>'Réglages généraux','GitHub API Token'=>'Token API GitHub','Templates Settings'=>'¨Paramètres des modèles','Archives Settings'=>'Paramètres des archives','Save Settings'=>'Enregistrer les paramètres','Settings saved successfully.'=>'Réglages bien enregistrés.','Projects'=>'Projets','Project'=>'Projet','Add New Project'=>'Ajouter un nouveau projet','Project Types'=>'Types de projets','Project Type'=>'Type de projet','Plugin'=>'Extension','Theme'=>'Thème','Pattern'=>'Motif','GitHub URL'=>'URL GitHub','GitHub Repository URL:'=>'URL du dépôt GitHub :','View details'=>'Voir les détails','More information about %s'=>'Plus dinformations sur %s','Check for updates'=>'Vérifier les mises à jour','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'Le plugin %s est à jour.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Une nouvelle version de l\'extension %s est disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'Impossible de déterminer si des mises à jour sont disponibles pour %s.','Unknown update checker status "%s"'=>'Mise à jour inconnue statut "%s"','There is no changelog available.'=>'Aucun registre de modifications disponible.','Invalid download URL. Please check the GitHub repository.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub.','Invalid download URL. Please check the repository URL.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt.','Repository'=>'','Provider'=>'','Gitea Base URL (optional override)'=>'','Leave blank to use the default from Settings.'=>'','View Repo'=>'','Gitea Settings'=>'','Default Gitea Base URL'=>'','Used when a Gitea project has no per-project base URL override.'=>'','Gitea API Token'=>'','Download Error'=>'Télécharger Erreur','Download Count'=>'Nombre de Téléchargements','0'=>'0','Download'=>'Télécharger','View Project'=>'Voir le projet','&laquo; Previous'=>'&laquo; Précédent','Next &raquo;'=>'Suivant &raquo;','No projects found.'=>'Aucun projet trouvé.','Project Banner'=>'Bannière du projet','Download Now'=>'Télécharger maintenant','View on GitHub'=>'Voir sur GitHub','GitHub Repository:'=>'Dépôt GitHub:','No repository linked.'=>'Aucun dépôt n\'est lié.','Version:'=>'Version:','Updated:'=>'Mis à jour :','License:'=>'Licence:','Language:'=>'Langue:','Downloads:'=>'Téléchargements:','Stars:'=>'Étoiles:','Forks:'=>'Forks:','Issues:'=>'Problèmes:','Follow'=>'Suivre','Projects for WordPress®'=>'Projets pour WordPress®','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Créer un répertoire de présentation pour les projets (plugins, thèmes, modèles) avec des types d\'articles personnalisés, des taxonomies et des fonctionnalités de téléchargement.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
+40
View File
@@ -88,6 +88,42 @@ msgstr "Réglages généraux"
msgid "GitHub API Token"
msgstr "Token API GitHub"
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:86
msgid "Templates Settings"
msgstr "¨Paramètres des modèles"
@@ -189,6 +225,10 @@ msgstr "Aucun registre de modifications disponible."
msgid "Invalid download URL. Please check the GitHub repository."
msgstr "URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub."
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr "URL de téléchargement invalide. Veuillez vérifier le dépôt."
#: projects-portfolio.php:118
msgid "Download Error"
msgstr "Télécharger Erreur"
+40
View File
@@ -189,10 +189,50 @@ msgstr ""
msgid "Invalid download URL. Please check the GitHub repository."
msgstr ""
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr ""
#: projects-portfolio.php:118
msgid "Download Error"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: projects-portfolio.php:133
msgid "Download Count"
msgstr ""
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true">
<testsuites>
<testsuite name="projects-portfolio">
<directory suffix=".php">tests</directory>
</testsuite>
</testsuites>
</phpunit>
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
# Release Workflow Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a GitHub Actions workflow that, on every `v*` tag push, builds `projects-portfolio-v<version>.zip` from an allowlist of runtime files and publishes it both as a workflow artifact and as an attachment to a matching GitHub release.
**Architecture:** Single workflow file at `.github/workflows/release.yml`. One job (`release`) on `ubuntu-latest` performs: checkout → bash build step (allowlisted `cp -r` into a staging dir, then `zip -r`) → `actions/upload-artifact@v4``softprops/action-gh-release@v2`. Uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission.
**Tech Stack:** GitHub Actions YAML, bash, `zip` CLI (preinstalled on `ubuntu-latest`), `actions/checkout@v4`, `actions/upload-artifact@v4`, `softprops/action-gh-release@v2`.
## Global Constraints
- Workflow file lives at `.github/workflows/release.yml`.
- Trigger: `on.push.tags: ['v*']`. No `workflow_dispatch`.
- Permission: `contents: write`.
- Zip filename: `projects-portfolio-v<version>.zip` where `<version>` is `GITHUB_REF_NAME` with leading `v` stripped.
- Zip internal layout: `projects-portfolio/` directory at zip root, with `projects-portfolio.php` inside it (WordPress install-from-zip convention).
- Allowlisted source paths copied into staging: `projects-portfolio.php`, `README.md`, `LICENSE`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`.
- Excluded paths (no `cp` of these): `composer.json`, `composer.lock`, `vendor/`, `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`.
- Build step uses `set -euo pipefail` and an `unzip -l "$OUT"` debug print at the end so the action log self-documents the contents.
- Artifact upload uses `if-no-files-found: error` so a missing zip fails the job.
- `softprops/action-gh-release@v2` is given the tag name, release name = tag name, `generate_release_notes: true`, and `files:` pointing at the zip.
- README gets a "Building a Release" section after "Connect Your Gitea Repo".
- First release is `v1.1.1`: implementation includes a version bump from `1.1.0``1.1.1` (header `Version:` + `PROJECTS_PORTFOLIO_VERSION` constant).
- No PHP files outside of the version bump are touched. The PHPUnit suite is unaffected.
## File Structure
**Created:**
- `.github/workflows/release.yml` — the workflow.
**Modified:**
- `README.md` — adds a "Building a Release" section after the existing "Connect Your Gitea Repo" section.
- `projects-portfolio.php` — bumps header `Version:` and `PROJECTS_PORTFOLIO_VERSION` from `1.1.0` to `1.1.1` as part of the first-release rollout.
No other files touched. No tests added.
---
## Task 1: Add the release workflow file
**Files:**
- Create: `.github/workflows/release.yml`
**Interfaces:**
- Produces: a workflow that triggers on `v*` tag push and produces a release zip + GitHub release.
- [ ] **Step 1: Create `.github/workflows/release.yml`**
Create the directory if missing, then create the file with this exact content:
```yaml
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Build & publish plugin zip
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build plugin zip
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
STAGE="dist/staging"
OUT="dist/projects-portfolio-v${VERSION}.zip"
rm -rf dist
mkdir -p "$STAGE/projects-portfolio"
cp projects-portfolio.php "$STAGE/projects-portfolio/"
cp README.md "$STAGE/projects-portfolio/"
cp LICENSE "$STAGE/projects-portfolio/"
cp -r admin/ "$STAGE/projects-portfolio/"
cp -r assets/ "$STAGE/projects-portfolio/"
cp -r includes/ "$STAGE/projects-portfolio/"
cp -r languages/ "$STAGE/projects-portfolio/"
cp -r templates/ "$STAGE/projects-portfolio/"
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
echo "Built $OUT"
unzip -l "$OUT"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: projects-portfolio-v${{ github.ref_name }}
path: dist/projects-portfolio-*.zip
if-no-files-found: error
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: dist/projects-portfolio-*.zip
```
- [ ] **Step 2: Validate YAML syntax locally**
Run from the project root:
```bash
php -r "require 'vendor/autoload.php';" 2>/dev/null || true
# PHP doesn't have a built-in YAML parser; use Python's if available
python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml').read()); print('OK')"
```
If `python` is not on PATH, use any other YAML validator you have. If none is available, skip this step — GitHub's own workflow validation will catch syntax errors on the next push.
Expected (if `python` is available): `OK` on stdout, exit 0.
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/release.yml
git commit -m "Add GitHub Actions release workflow"
```
---
## Task 2: Add "Building a Release" section to README
**Files:**
- Modify: `README.md`
**Interfaces:**
- Produces: a "Building a Release" section placed after the "Connect Your Gitea Repo" section (which was added in the previous feature).
- [ ] **Step 1: Insert the new section**
Find the existing `## Connect Your Gitea Repo` section in `README.md`. Locate the heading `## Building a Release` (it does not yet exist; that's the insertion target).
Insert the following block directly after the closing line of the Gitea repo subsection (`Paste it into the plugin settings screen under **Gitea API Token**.`) and before any subsequent `## ` heading:
```markdown
## Building a Release
Releases are automated via GitHub Actions. To cut a new release:
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
2. Commit and push to `main`.
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
4. Push the tag: `git push origin v1.1.0`.
The `.github/workflows/release.yml` workflow runs and:
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
- Uploads the zip as a workflow artifact.
- Creates (or updates) the matching GitHub Release with the zip attached.
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
```
- [ ] **Step 2: Verify with grep**
Run:
```bash
grep -n "Building a Release" README.md
```
Expected: one matching line, located after the "Connect Your Gitea Repo" section.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "Document automated release process in README"
```
---
## Task 3: Bump version to 1.1.1 for first automated release
**Files:**
- Modify: `projects-portfolio.php` — header `Version:` and `PROJECTS_PORTFOLIO_VERSION` constant.
**Interfaces:**
- Produces: in-tree version `1.1.1`, header comment line `* Version: 1.1.1`, constant `define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );`.
- [ ] **Step 1: Update the plugin header**
In `projects-portfolio.php`, in the file header comment block near the top, change:
```
* Version: 1.1.0
```
to:
```
* Version: 1.1.1
```
- [ ] **Step 2: Update the version constant**
In `projects-portfolio.php`, find the line:
```php
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
```
Replace with:
```php
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );
```
- [ ] **Step 3: Run the test suite to confirm no regression**
Run: `php vendor/bin/phpunit`
Expected: `OK (24 tests, 45 assertions)` (same as before — no PHP behavior change).
- [ ] **Step 4: Verify with grep**
Run:
```bash
grep -n "Version:" projects-portfolio.php | head -1
grep -n "PROJECTS_PORTFOLIO_VERSION" projects-portfolio.php
```
Expected output:
- First grep: a line containing `* Version: 1.1.1`.
- Second grep: two lines — the `define(...)` and (possibly) usage sites. The `define` line must show `'1.1.1'`.
- [ ] **Step 5: Commit**
```bash
git add projects-portfolio.php
git commit -m "Bump version to 1.1.1 for first automated release"
```
---
## Task 4: Final verification
**Files:** none (verification only).
- [ ] **Step 1: Confirm all three commits landed**
Run:
```bash
git log --oneline -5
```
Expected (most recent at top): three commits from this plan are visible:
- "Bump version to 1.1.1 for first automated release"
- "Document automated release process in README"
- "Add GitHub Actions release workflow"
- [ ] **Step 2: Confirm working tree is clean**
Run: `git status`
Expected: `nothing to commit, working tree clean`.
- [ ] **Step 3: Run full PHPUnit suite**
Run: `php vendor/bin/phpunit`
Expected: `OK (24 tests, 45 assertions)`.
- [ ] **Step 4: Confirm workflow file content**
Run:
```bash
head -30 .github/workflows/release.yml
```
Expected: matches the YAML produced in Task 1, with `name: Release`, `on.push.tags: ['v*']`, `permissions.contents: write`, and the `release` job.
- [ ] **Step 5: Confirm README has the new section**
Run:
```bash
grep -c "Building a Release" README.md
```
Expected: `1`.
- [ ] **Step 6: Note (do NOT execute): the first automated release**
The user will tag `v1.1.1` and push the tag themselves, which triggers the workflow. The workflow file is in place; the in-tree version is bumped; the first release will produce a zip with this code. The implementer does NOT push the tag or the branch — that's the user's call (their existing git history is ahead of `origin/main`).
- [ ] **Step 7: Final commit if anything changed**
```bash
git status
# If clean, skip. Otherwise:
git add -A
git commit -m "Final verification fixes"
```
+32 -28
View File
@@ -11,7 +11,7 @@
* Plugin Name: Projects Portfolio
* 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
* Version: 1.0.0
* Version: 1.2.0
* Author: Keith Solomon
* Author URI: https://keithsolomon.net
* License: GPL-2.0+
@@ -34,18 +34,22 @@ $myUpdateChecker = PucFactory::buildUpdateChecker(
__FILE__,
'projects-portfolio'
);
// Set the branch that contains the stable release.
$myUpdateChecker->setBranch( 'main' );
// Note: PUC's setBranch() is only available when the host is recognized as a
// VCS provider (GitHub, GitLab, Bitbucket). The Gitea URL here falls back to
// PUC's plain JSON metadata mode, so we skip the call rather than throw.
// Current plugin version.
define( 'PROJECTS_PORTFOLIO_VERSION', time() );
define( 'PROJECTS_PORTFOLIO_VERSION', '1.2.0' );
// Add the required files.
require 'admin/admin-settings.php';
require 'admin/cpt-taxonomy.php';
require 'admin/metabox.php';
require 'includes/helper-functions.php';
require 'includes/providers/interface-repository-provider.php';
require 'includes/providers/class-github-provider.php';
require 'includes/providers/class-gitea-provider.php';
require 'includes/providers/class-provider-factory.php';
/**
* Flush rewrite rules on activation.
@@ -99,8 +103,7 @@ function projects_portfolio_handle_download_redirect() {
$project_id = get_query_var( 'project_download_id' );
if ( $project_id ) {
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$download_url = projects_portfolio_get_github_release_url( $github_url );
$download_url = projects_portfolio_get_release_url( (int) $project_id );
if ( $download_url ) {
// Increment download count.
@@ -108,12 +111,15 @@ function projects_portfolio_handle_download_redirect() {
++$download_count;
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
// Redirect to the GitHub ZIP file.
wp_safe_redirect( esc_url_raw( $download_url ) );
// Redirect to the release ZIP on the project's host.
// 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;
} else {
wp_die(
esc_html__( 'Invalid download URL. Please check the GitHub repository.', 'projects-wp' ),
esc_html__( 'Invalid download URL. Please check the repository URL.', 'projects-wp' ),
esc_html__( 'Download Error', 'projects-wp' ),
array( 'response' => 404 )
);
@@ -265,16 +271,6 @@ function projects_portfolio_enqueue_project_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' );
@@ -348,39 +344,47 @@ function projects_portfolio_social_sharing_buttons( $project_id ) {
$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.
$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>';
// X (Twitter).
$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>';
// LinkedIn.
$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>';
// Reddit.
$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>';
// WhatsApp.
$html .= '<a href="https://wa.me/?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>';
$html .= '<a href="https://www.whatsapp.com/send?text=' . $title . '%20' . $url . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'WhatsApp', 'whatsapp.svg' );
$html .= '</a>';
// Pinterest.
$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>';
// Email.
$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 .= '</div>';
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Helper script for the release workflow (.github/workflows/release.yml).
Subcommands:
build-payload [--github] Build the JSON payload for creating a release.
Reads TAG, GH_SHA from env.
With --github, includes generate_release_notes=True.
Writes JSON to stdout.
extract-id < response.json Print the release id from a release API response.
extract-upload-url < response.json Print the upload_url from a GitHub release
response (used as the asset upload endpoint template).
This script exists to avoid bash heredoc-inside-YAML-literal indentation traps.
"""
import json
import os
import sys
def build_payload(github: bool) -> int:
tag = os.environ.get("TAG", "")
sha = os.environ.get("GH_SHA", "unknown")
payload = {
"tag_name": tag,
"name": tag,
"body": (
"Automated release.\n\n"
f"Built from commit {sha}.\n\n"
"See the workflow run for the artifact."
),
"draft": False,
"prerelease": False,
}
if github:
payload["generate_release_notes"] = True
print(json.dumps(payload))
return 0
def extract_field(field: str) -> int:
raw = sys.stdin.read()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print(f"::error::Failed to parse JSON response: {e}", file=sys.stderr)
return 1
value = data.get(field, "")
if not value:
print(f"::error::Field {field!r} missing from response: {raw}", file=sys.stderr)
return 1
print(value)
return 0
def usage() -> int:
print(__doc__, file=sys.stderr)
return 2
def main() -> int:
if len(sys.argv) < 2:
return usage()
cmd = sys.argv[1]
if cmd == "build-payload":
return build_payload(github="--github" in sys.argv)
if cmd == "extract-id":
return extract_field("id")
if cmd == "extract-upload-url":
return extract_field("upload_url")
if cmd == "extract-tag":
return extract_field("tag_name")
return usage()
if __name__ == "__main__":
sys.exit(main())
+271
View File
@@ -0,0 +1,271 @@
# Gitea Support for Projects Portfolio
- **Date:** 2026-08-09
- **Plugin:** Projects Portfolio (`projects-wp`)
- **Status:** Approved design, awaiting implementation plan
## 1. Goals
- Per-project support for either GitHub OR Gitea (self-hosted, configurable base URL).
- Feature parity between providers: repo metadata, latest version, release-ZIP download redirect, repo link button.
- Existing GitHub behavior preserved bit-for-bit — no regressions on current installs.
- Provider interface designed so adding a third provider later (e.g. GitLab) is additive and does not require template branching.
- WP PHPUnit tests cover the new adapters, the provider factory, and the metabox/settings surface.
## 2. Non-goals
- GitLab, Bitbucket, or any non-GitHub/non-Gitea provider (interface permits it; implementation deferred).
- Auto-migration on activation (existing posts keep their GitHub association; admin manually switches a project to Gitea when desired).
- Public-Codeberg-only shortcut — Gitea support always treats the base URL as configurable per project and globally.
- New CPT or taxonomy fields beyond what's listed in this spec.
- UI changes outside the project metabox and the plugin settings page.
## 3. Architecture
The plugin introduces a small `Repository_Provider` interface with two adapters and a factory. All call sites consume the interface; only the factory knows which adapter to use.
```
Templates + REST API + Download Redirect
projects_portfolio_get_provider( $post_id ) ← factory
┌────────┴────────┐
▼ ▼
GitHub_Provider Gitea_Provider
│ │
└────────┬────────┘
WP HTTP API + transient cache
```
### 3.1 Files added
- `includes/providers/interface-repository-provider.php``Repository_Provider` interface.
- `includes/providers/class-github-provider.php` — current GitHub logic, repackaged.
- `includes/providers/class-gitea-provider.php` — new adapter.
- `includes/providers/class-provider-factory.php``projects_portfolio_get_provider()`.
- `tests/bootstrap.php`, `tests/test-*.php` — WP PHPUnit suite.
- `composer.json` (dev-only) — pulls in `phpunit/phpunit` and `brain/monkey`.
### 3.2 Files modified
- `includes/helper-functions.php` — adds provider-aware wrappers; legacy GitHub-named wrappers retained verbatim with deprecation PHPDoc.
- `admin/metabox.php` — replaces single GitHub URL field with provider dropdown + repo URL + optional Gitea base URL.
- `admin/admin-settings.php` — adds "Gitea Settings" section (default base URL + token).
- `templates/single-projects.php` — routes data fetches through the provider; no per-provider branching.
- `admin/rest-api.php` — same swap for the REST response builder.
- `projects-portfolio.php``handle_download_redirect()` routes through the provider.
- `README.md` — documents the new metabox fields, Gitea settings, and a "Connecting to a Gitea repo" subsection.
- `languages/projects-wp.pot` (and `.po`/`.l10n.php` siblings) — re-generated for new strings.
## 4. 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; // direct asset zip URL or null
public function get_latest_version(): string; // tag_name or 'Unknown'
public function get_repo_browse_url(): string; // for the 'View Repo' button
public function get_owner_data( string $owner_login ): ?array; // avatar/login/html_url
}
```
## 5. Per-project storage
Post meta keys:
| Key | Type | Purpose |
|---|---|---|
| `_projects_portfolio_provider` | `'github'` \| `'gitea'` | Provider selection. Defaults to `github` if missing. |
| `_projects_portfolio_repo_url` | string (URL) | Canonical repo URL. On read, falls back to `_projects_portfolio_github_url` for backward compatibility. |
| `_projects_portfolio_gitea_base_url` | string (URL) | Optional per-project Gitea base URL override. Empty → uses global default. |
| `_projects_portfolio_github_url` | string | **Deprecated, read-only.** Retained so legacy posts don't lose data. |
| `_projects_portfolio_download_count` | int | Unchanged. |
**Lazy migration:** in `projects_portfolio_save_meta_box()`, when a project is saved and `_projects_portfolio_provider` is unset, `_projects_portfolio_repo_url` is empty, but the legacy `_projects_portfolio_github_url` is non-empty, copy the legacy value into `_projects_portfolio_repo_url` and set provider to `github`. No batch update is required.
## 6. Normalized repo data shape
Templates and the REST API read this shape regardless of provider. The GitHub adapter returns it natively; the Gitea adapter maps from Gitea's native response.
```php
[
'owner' => [
'avatar_url' => string,
'login' => string,
'html_url' => string,
],
'updated_at' => string, // ISO 8601
'language' => string,
'license' => [ 'name' => string ],
'stargazers_count' => int,
'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) | `['name' => string\|'None']` |
| `owner.avatar_url` | `owner.avatar_url` |
| `owner.login` | `owner.login` |
| `owner.html_url` (or fallback `{base}/{owner}`) | `owner.html_url` |
## 7. Endpoints
| Purpose | GitHub | Gitea |
|---|---|---|
| Repo metadata | `https://api.github.com/repos/{owner}/{repo}` | `{base}/api/v1/repos/{owner}/{repo}` |
| Latest release | `…/{owner}/{repo}/releases/latest` | `{base}/api/v1/repos/{owner}/{repo}/releases/latest` |
| Owner data | `https://api.github.com/users/{login}` | `{base}/api/v1/users/{login}` |
| Repo browse | the project URL itself | `{base}/{owner}/{repo}` |
Gitea uses header `Authorization: token <PAT>` against the `projects_portfolio_gitea_api_token` option. GitHub uses the same header scheme against the existing `projects_portfolio_github_api_token` option.
## 8. Release & download logic
**GitHub adapter** (preserves current behavior):
1. Fetch release at `…/releases/latest`.
2. Iterate `assets[]` and return the first entry whose filename ends in `.zip` (field `browser_download_url`).
3. Fallback: return `zipball_url`.
4. `null` if neither is present.
**Gitea adapter**:
1. Fetch the latest release endpoint. Gitea returns an **array** of releases (not a single object), so the adapter takes `releases[0]`.
2. Iterate that release's `assets[]` for the first `.zip` entry.
3. Fallback: build `{base}/{owner}/{repo}/archive/refs/tags/{tag_name}.zip` (Gitea serves this even when no assets are attached).
4. `null` if no tag is present.
5. `get_latest_version()` reads `tag_name` from the same response.
## 9. Global settings
Added to the settings page (between General and Templates sections):
| Option | Default | Purpose |
|---|---|---|
| `projects_portfolio_default_gitea_base_url` | `https://codeberg.org` | Used when a Gitea project has no per-project base URL override. |
| `projects_portfolio_gitea_api_token` | `''` | Bearer token for authenticated Gitea requests. |
Existing `projects_portfolio_github_api_token` is unchanged.
`projects_portfolio_settings()` returns the new keys so templates can read them.
## 10. Metabox
The "GitHub URL" metabox becomes a "Repository" metabox with:
1. **Provider**`<select>` with options `github` (GitHub) and `gitea` (Gitea). Defaults to `github`.
2. **Repository URL**`<input type="url">`. Helper text: "Example: `https://github.com/owner/repo` or `https://codeberg.org/owner/repo`."
3. **Gitea Base URL (optional override)**`<input type="url">`. Visible only when Provider = Gitea. Helper text: "Leave blank to use the default from Settings."
Save behavior writes `_projects_portfolio_provider`, `_projects_portfolio_repo_url`, and `_projects_portfolio_gitea_base_url`. The legacy `_projects_portfolio_github_url` is written **only** during the one-time lazy migration (§5); it is not written on every save.
## 11. Helper-function surface
New provider-aware wrappers (in `includes/helper-functions.php`):
```php
projects_portfolio_get_provider( $post_id ): Repository_Provider
projects_portfolio_get_repo_data( $post_id ): ?array
projects_portfolio_get_release_url( $post_id ): ?string
projects_portfolio_get_version( $post_id ): string
projects_portfolio_get_owner( $post_id ): ?array
projects_portfolio_get_repo_browse_url( $post_id ): string
```
Legacy GitHub-named wrappers are retained **verbatim** with a `_deprecated_function()` PHPDoc note pointing to the new wrappers:
```php
projects_portfolio_get_github_data( $github_url ): ?array
projects_portfolio_get_github_release_url( $github_url ): ?string
projects_portfolio_get_version_from_github( $github_url ): string
projects_portfolio_github_owner( $owner_name ): ?array
```
These wrappers still take a GitHub URL and never route through the provider interface, preserving any external code that called them directly.
## 12. Call-site swaps
1. **`projects-portfolio.php``projects_portfolio_handle_download_redirect()`**
- Read `_projects_portfolio_repo_url` (with legacy fallback) and `_projects_portfolio_provider`.
- Call `projects_portfolio_get_release_url( $post_id )`.
- Error message: "Invalid download URL. Please check the repository URL."
2. **`templates/single-projects.php`**
- `$repo_data = projects_portfolio_get_repo_data( $post_id );`
- `$version = projects_portfolio_get_version( $post_id );`
- `$owner = projects_portfolio_get_owner( $post_id );`
- "View on GitHub" → "View Repo", href = `projects_portfolio_get_repo_browse_url( $post_id )`.
- Field reads (`$repo_data['stargazers_count']` etc.) unchanged because adapters normalize.
3. **`admin/rest-api.php`**
- `projects_portfolio_get_projects_data()` uses `projects_portfolio_get_repo_data()` and `projects_portfolio_get_version()`. Response shape is unchanged.
## 13. Error handling
| Scenario | Behavior |
|---|---|
| Provider meta missing | Default to `github`; fall back to legacy `_projects_portfolio_github_url`. |
| Repo URL invalid/empty | Provider returns `null`; templates omit provider-only rows; download redirect shows wp_die. |
| API 401/403 | Logged, treated as `null`. |
| API 429 | Logged with reset time, treated as `null`. |
| Gitea release array empty | `get_release_url()` returns `null`. |
| Gitea release with no zip asset | Falls back to archive URL pattern. |
| Gitea license = string | Adapter wraps to `['name' => $string]`. |
| Gitea license = null | Adapter returns `['name' => 'None']`. |
| Cache transient corruption | Treated as a miss; re-fetched. |
| Owner fetch failure | Returns `null`; owner avatar block is omitted. |
All errors are logged via `error_log()` with a `Projects Portfolio:` prefix. **No `die()` calls in adapter code** — the legacy wrappers retain their existing `die()` behavior for backward compatibility.
## 14. Testing
WP PHPUnit suite under `tests/`:
- `test-github-provider.php` — correct URL construction, header behavior, asset/zipball fallback, multi-asset selection.
- `test-gitea-provider.php` — correct URL construction, Gitea→normalized mapping, releases-as-array handling, archive-URL fallback, license normalization.
- `test-provider-factory.php` — provider selection from meta; legacy URL fallback; token wiring per provider.
- `test-metabox.php` — save persists new meta keys; legacy key preserved; lazy migration from legacy → new repo URL.
- `test-settings.php` — new options register, save, and sanitize correctly.
Local run: `composer install && vendor/bin/phpunit`.
## 15. Rollout
1. Implement on a feature branch.
2. Manual smoke tests:
- GitHub project on fresh install — output matches current plugin exactly.
- Gitea project on codeberg.org.
- Self-hosted Gitea with custom base URL.
- Project with empty repo URL — download endpoint shows wp_die gracefully.
3. Run the WP PHPUnit suite.
4. Update README per §3.2.
5. Bump `PROJECTS_PORTFOLIO_VERSION` and plugin header Version `1.0.0``1.1.0`.
6. Release as a backward-compatible minor version.
## 16. New translatable strings
- "Repository" (metabox title)
- "Provider"
- "GitHub" (option label)
- "Gitea" (option label)
- "Repository URL"
- "Gitea Base URL (optional override)"
- "Leave blank to use the default from Settings."
- "View Repo"
- "Invalid download URL. Please check the repository URL."
- "Gitea Settings" (settings section heading)
- "Default Gitea Base URL"
- "Gitea API Token"
All strings use the existing `projects-wp` text domain. POT regeneration picks them up.
+166
View File
@@ -0,0 +1,166 @@
# Release Workflow Design
- **Date:** 2026-08-11
- **Plugin:** Projects Portfolio (`projects-wp`)
- **Status:** Approved design, awaiting implementation plan
## 1. Goals
- A single tag push (`v*`) automatically produces a release-ready zip named `projects-portfolio-v<version>.zip`.
- The zip contains exactly the files WordPress needs to install and use the plugin: `projects-portfolio.php` at the root of `projects-portfolio/`, with `admin/`, `assets/`, `includes/`, `languages/`, `templates/` subdirectories plus `README.md` and `LICENSE`.
- The same zip is uploaded as a workflow artifact AND attached to a GitHub release matching the tag.
- README gets a short "Building a release" section explaining how the workflow runs and what it produces.
## 2. Non-goals
- Publishing to WordPress.org — out of scope (different credentials, different approval workflow).
- Cross-repo publishing or PAT-based auth.
- Signing, cosign, or checksums for the zip.
- Auto-bumping `PROJECTS_PORTFOLIO_VERSION` or the plugin header before a tag is cut. The workflow assumes the tag is created *after* the version has been bumped in-tree.
- Backporting tags. The workflow only acts on the tag push, not on retags of older SHAs.
## 3. Architecture
A single GitHub Actions workflow file at `.github/workflows/release.yml` runs on every `v*` tag push. It performs four sequential steps inside a single job:
1. **Checkout**`actions/checkout@v4` at the tag's commit.
2. **Build zip** — bash script copies allowlisted runtime files into a staging directory `dist/staging/projects-portfolio/`, then runs `zip -r` from the staging root to produce `dist/projects-portfolio-v<version>.zip`.
3. **Upload artifact**`actions/upload-artifact@v4` with name `projects-portfolio-v<tag>` and path `dist/projects-portfolio-*.zip`. Fails if no file matches.
4. **Create release**`softprops/action-gh-release@v2` with the tag, generated release notes, and the zip file. Replaces the asset if a release with the same tag already exists (default `action-gh-release@v2` behavior).
The workflow uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission. No additional secrets are needed.
### 3.1 Why a staging directory
WordPress's "Upload Plugin" zip-install expects `<plugin-slug>/<plugin-file>.php` at the zip root. A direct `zip -r` of the working tree would put everything at the zip root — no enclosing folder — which would fail to install. The staging directory ensures the zip's internal layout has `projects-portfolio/` at the top with `projects-portfolio.php` inside it.
### 3.2 Files added
- `.github/workflows/release.yml` — the workflow.
- `README.md` — modified, with a new "Building a release" section after the existing "Connect Your Gitea Repo" section.
No PHP files are touched. The PHPUnit suite is unaffected (no test bootstrap or production code changes).
## 4. Trigger & permissions
```yaml
on:
push:
tags:
- 'v*'
permissions:
contents: write
```
`workflow_dispatch` is intentionally omitted. To re-run for an existing tag, use the GitHub UI's "Re-run all jobs" button.
## 5. Zip filename
`projects-portfolio-v<version>.zip`, where `<version>` is the tag name with the leading `v` stripped. Examples:
- Tag `v1.1.0``projects-portfolio-v1.1.0.zip`
- Tag `v2.0.0-rc1``projects-portfolio-v2.0.0-rc1.zip`
## 6. Allowlisted zip contents
The build step copies these paths from the working tree into `dist/staging/projects-portfolio/`:
```
projects-portfolio.php
README.md
LICENSE
admin/
assets/
includes/
languages/
templates/
```
The bundled `includes/plugin-update-checker/` library IS included — it's a runtime dependency used by `projects-portfolio.php`.
## 7. Excluded paths
The build step does **not** copy any of these. Most are already not tracked in git; the explicit list prevents accidental inclusion if any are added later:
- `composer.json`, `composer.lock` — dev-only.
- `vendor/` — Composer dev dependencies.
- `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache` — test infrastructure.
- `plans/`, `specs/` — design artifacts (not tracked, but listed for safety).
- `.github/` — the workflow file itself (would otherwise be included by the catch-all `cp -r`).
- `.gitignore`, `.vscode/`, `.claude/` — editor / project config (not tracked except `.gitignore`).
If the build script can't find a tracked source path it expects (e.g. `projects-portfolio.php` renamed), `set -e` fails the step.
## 8. Build step (verbatim)
```bash
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
STAGE="dist/staging"
OUT="dist/projects-portfolio-v${VERSION}.zip"
rm -rf dist
mkdir -p "$STAGE/projects-portfolio"
cp projects-portfolio.php "$STAGE/projects-portfolio/"
cp README.md "$STAGE/projects-portfolio/"
cp LICENSE "$STAGE/projects-portfolio/"
cp -r admin/ "$STAGE/projects-portfolio/"
cp -r assets/ "$STAGE/projects-portfolio/"
cp -r includes/ "$STAGE/projects-portfolio/"
cp -r languages/ "$STAGE/projects-portfolio/"
cp -r templates/ "$STAGE/projects-portfolio/"
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
echo "Built $OUT"
unzip -l "$OUT"
```
`unzip -l` prints the table of contents to the action log so the run is self-documenting.
## 9. README addition
Add a "Building a Release" section after the existing "Connect Your Gitea Repo" section:
```markdown
## Building a Release
Releases are automated via GitHub Actions. To cut a new release:
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
2. Commit and push to `main`.
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
4. Push the tag: `git push origin v1.1.0`.
The `.github/workflows/release.yml` workflow runs and:
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
- Uploads the zip as a workflow artifact.
- Creates (or updates) the matching GitHub Release with the zip attached.
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
```
## 10. Error handling
| Failure | Behavior |
|---|---|
| Tag doesn't match `v*` | Workflow doesn't run. |
| `cp -r` finds a missing source path | `set -e` fails the step; no zip, no release. |
| `zip` command fails | `set -e` fails the step; no artifact upload, no release. |
| Artifact upload finds no file | `if-no-files-found: error` fails the step. |
| Release already exists for tag | `softprops/action-gh-release@v2` replaces the asset and updates notes (default behavior). |
| Missing `contents: write` permission | Job fails at checkout or release step with a clear permissions error. |
## 11. Rollout
1. Implement on a feature branch.
2. Merge into `main` (the workflow file is tracked and applies to all future tags).
3. To produce the first automated release: bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header from `1.1.0` to `1.1.1`, commit, tag `v1.1.1`, push the tag.
4. Verify on the GitHub Actions run page that: artifact uploads, zip contents look right (via the `unzip -l` log line), GitHub release is created with the zip attached.
## 12. New translatable strings
None. The workflow file and README change introduce no user-facing strings.
+43 -90
View File
@@ -4,7 +4,7 @@ $is_fse_theme = wp_theme_has_theme_json() && function_exists( 'wp_is_block_theme
if ( $is_fse_theme ) {
wp_head();
echo do_blocks( '<!-- wp:template-part {"slug":"header"} /-->' );
echo do_blocks( '<!-- wp:template-part {"slug":"header"} /-->' ); // phpcs:ignore
} else {
get_header();
}
@@ -12,73 +12,24 @@ if ( $is_fse_theme ) {
$settings = projects_portfolio_settings();
$project_id = get_the_ID();
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$version = 'Unknown';
$github_data = projects_portfolio_get_github_data( $github_url );
// Extract specific GitHub data.
$owner_avatar = $github_data['owner']['avatar_url'] ?? '';
$owner_name = $github_data['owner']['login'] ?? '';
$owner_url = $github_data['owner']['html_url'] ?? '';
$last_updated = $github_data['updated_at'] ?? '';
$language = $github_data['language'] ?? '';
$license = $github_data['license']['name'] ?? 'None';
$owner = projects_portfolio_github_owner( $owner_name );
// Fetch the version number from the GitHub API if the URL is set.
if ( $github_url ) {
// Retrieve the GitHub API token from the plugin settings.
$github_token = get_option( 'projects_portfolio_github_api_token', '' );
// Prepare the API URL.
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest';
// Check if the response is cached.
$transient_key = 'github_api_response_' . md5( $api_url );
$response = get_transient( $transient_key );
if ( false === $response ) {
// Prepare the request arguments.
$args = [];
if ( ! empty( $github_token ) ) {
$args['headers'] = [
'Authorization' => 'token ' . $github_token,
];
}
// Make the API request.
$response = wp_remote_get( $api_url, $args );
// Cache the response for 1 hour if the request is successful.
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
set_transient( $transient_key, $response, HOUR_IN_SECONDS );
}
}
// Handle the response.
if ( is_wp_error( $response ) ) {
error_log( 'GitHub API error: ' . $response->get_error_message() );
} else {
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = wp_remote_retrieve_body( $response );
error_log( 'GitHub API response code: ' . $response_code );
error_log( 'GitHub API response body: ' . $response_body );
if ( $response_code === 200 ) {
$data = json_decode( $response_body, true );
// Check if tag_name exists in the response.
if ( isset( $data['tag_name'] ) ) {
$version = esc_html( $data['tag_name'] );
}
} else {
error_log( "GitHub API error: HTTP $response_code for URL $api_url" );
}
}
$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 );
}
// Format last updated date.
$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 ) {
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
}
@@ -117,13 +68,14 @@ 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 ( $github_url ) : ?>
<a href="<?php echo esc_url( $github_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View on GitHub', 'projects-wp' ); ?>
<?php if ( $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>
<?php else : ?>
<p><?php esc_html_e( 'GitHub 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 do_action( 'projects_after_download_button', $project_id ); ?>
</div>
<div class="project-meta">
@@ -163,25 +115,31 @@ if ( $last_updated ) {
</tr>
<?php endif; ?>
<?php
if ( ! empty( $github_data ) ) :
if ( isset( $github_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) : ?>
if ( ! empty( $repo_data ) ) :
if ( isset( $repo_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) :
?>
<tr>
<th><?php esc_html_e( 'Stars:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['stargazers_count'] ); ?></td>
<td><?php esc_html_e( $repo_data['stargazers_count'] ); ?></td>
</tr>
<?php endif;
if ( isset( $github_data['forks_count'] ) && $settings['templates']['forks'] ) : ?>
<?php
endif;
if ( isset( $repo_data['forks_count'] ) && $settings['templates']['forks'] ) :
?>
<tr>
<th><?php esc_html_e( 'Forks:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['forks_count'] ); ?></td>
<td><?php esc_html_e( $repo_data['forks_count'] ); ?></td>
</tr>
<?php endif;
if ( isset( $github_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) : ?>
<?php
endif;
if ( isset( $repo_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) :
?>
<tr>
<th><?php esc_html_e( 'Issues:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['open_issues_count'] ); ?></td>
<td><?php esc_html_e( $repo_data['open_issues_count'] ); ?></td>
</tr>
<?php endif;
<?php
endif;
endif;
?>
</tbody>
@@ -189,18 +147,13 @@ if ( $last_updated ) {
</div>
<?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
<a href="<?php esc_html_e( $github_url ); ?>">
<div class="project-owner">
<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-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">
<a href="<?php echo esc_url( $owner_url ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $owner_name ); ?></a>
</span>
<span>
<strong>
<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 class="project-owner-follow">
<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>
</span>
</div>
<?php endif; ?>
@@ -214,7 +167,7 @@ if ( $last_updated ) {
<?php
if ( $is_fse_theme ) {
wp_footer();
echo do_blocks( '<!-- wp:template-part {"slug":"footer"} /-->' );
echo do_blocks( '<!-- wp:template-part {"slug":"footer"} /-->' ); // phpcs:ignore
} else {
get_footer();
}
+21
View File
@@ -0,0 +1,21 @@
<?php
// Load Composer dev autoload (PHPUnit + Brain\Monkey + polyfills).
$autoload = dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! file_exists( $autoload ) ) {
fwrite( STDERR, "Run `composer install` before running tests.\n" );
exit( 1 );
}
require_once $autoload;
// Brain\Monkey sets up minimal function stubs (apply_filters, plugin_dir_path, etc.).
// Must run BEFORE the WP function stubs in wp-stubs.php are loaded, because Patchwork
// (used by Brain\Monkey) cannot redefine functions defined in the same file/import
// chain that loaded Patchwork itself. Loading the stubs in a separate file imported
// after Brain\Monkey\setUp() lets Patchwork track them properly.
\Brain\Monkey\setUp();
require_once __DIR__ . '/wp-stubs.php';
register_shutdown_function( function () {
\Brain\Monkey\tearDown();
} );
+200
View File
@@ -0,0 +1,200 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-gitea-provider.php';
/**
* @covers \Gitea_Provider
*/
class Gitea_Provider_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function make( string $base = 'https://codeberg.org', string $token = '' ): Gitea_Provider {
return new Gitea_Provider( 'https://codeberg.org/owner/repo', $base, $token );
}
public function test_get_id_and_label(): void {
$p = $this->make();
$this->assertSame( 'gitea', $p->get_id() );
$this->assertSame( 'Gitea', $p->get_label() );
}
public function test_repo_data_normalizes_gitea_fields(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->with( 'https://codeberg.org/api/v1/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [
'avatar_url' => 'https://codeberg.org/avatars/owner',
'login' => 'owner',
'html_url' => 'https://codeberg.org/owner',
],
'updated_at' => '2026-02-03T04:05:06Z',
'language' => 'PHP',
'license' => [ 'name' => 'MIT' ],
'stars_count' => 100,
'forks_count' => 10,
'open_issues_count' => 5,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 100, $data['stargazers_count'] );
$this->assertSame( 10, $data['forks_count'] );
$this->assertSame( 5, $data['open_issues_count'] );
$this->assertSame( 'MIT', $data['license']['name'] );
$this->assertSame( 'owner', $data['owner']['login'] );
}
public function test_repo_data_handles_string_license(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [],
'stars_count' => 0,
'forks_count' => 0,
'open_issues_count' => 0,
'license' => 'Apache-2.0',
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'Apache-2.0', $data['license']['name'] );
}
public function test_repo_data_handles_null_license(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [],
'stars_count' => 0,
'forks_count' => 0,
'open_issues_count' => 0,
'license' => null,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'None', $data['license']['name'] );
}
public function test_release_url_picks_zip_asset_from_first_release(): void {
$body = json_encode( [
[
'tag_name' => 'v2.0.0',
'assets' => [
[ 'name' => 'source.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ],
[ 'name' => 'release.zip', 'browser_download_url' => 'https://example/release.zip' ],
],
],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->twice()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
$this->assertSame( 'https://example/release.zip', $this->make()->get_release_url() );
$this->assertSame( 'v2.0.0', $this->make()->get_latest_version() );
}
public function test_release_url_falls_back_to_archive_url(): void {
$body = json_encode( [
[ 'tag_name' => 'v0.1.0', 'assets' => [] ],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
$this->assertSame(
'https://codeberg.org/owner/repo/archive/refs/tags/v0.1.0.zip',
$this->make()->get_release_url()
);
}
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 {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => '[]',
] );
$this->assertNull( $this->make()->get_release_url() );
}
public function test_repo_browse_url_is_built_from_base(): void {
$this->assertSame(
'https://codeberg.org/owner/repo',
$this->make()->get_repo_browse_url()
);
}
public function test_http_failure_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( new \WP_Error( 'http_error', 'boom' ) );
$this->assertNull( $this->make()->get_repo_data() );
}
public function test_self_hosted_base_url_is_used(): void {
$provider = new Gitea_Provider(
'https://git.example.org/owner/repo',
'https://git.example.org',
''
);
$this->assertSame(
'https://git.example.org/owner/repo',
$provider->get_repo_browse_url()
);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-github-provider.php';
/**
* @covers \GitHub_Provider
*/
class GitHub_Provider_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function make( string $token = '' ): GitHub_Provider {
return new GitHub_Provider( 'https://github.com/owner/repo', $token );
}
public function test_get_id_and_label(): void {
$p = $this->make();
$this->assertSame( 'github', $p->get_id() );
$this->assertSame( 'GitHub', $p->get_label() );
}
public function test_get_repo_data_normalizes_response(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->with( 'https://api.github.com/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [
'avatar_url' => 'https://avatars.example/owner.png',
'login' => 'owner',
'html_url' => 'https://github.com/owner',
],
'updated_at' => '2026-01-02T03:04:05Z',
'language' => 'PHP',
'license' => [ 'name' => 'MIT' ],
'stargazers_count' => 42,
'forks_count' => 7,
'open_issues_count' => 3,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'owner', $data['owner']['login'] );
$this->assertSame( 42, $data['stargazers_count'] );
$this->assertSame( 'MIT', $data['license']['name'] );
}
public function test_release_url_prefers_zip_asset(): void {
$release_body = json_encode( [
'tag_name' => 'v1.2.3',
'assets' => [
[ 'name' => 'project.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ],
[ 'name' => 'project.zip', 'browser_download_url' => 'https://example/x.zip' ],
],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->twice()
->with( 'https://api.github.com/repos/owner/repo/releases/latest', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $release_body,
] );
$this->assertSame( 'https://example/x.zip', $this->make()->get_release_url() );
$this->assertSame( 'v1.2.3', $this->make()->get_latest_version() );
}
public function test_release_url_falls_back_to_zipball(): void {
$release_body = json_encode( [
'tag_name' => 'v0.0.1',
'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v0.0.1',
'assets' => [],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $release_body,
] );
$this->assertSame(
'https://api.github.com/repos/owner/repo/zipball/v0.0.1',
$this->make()->get_release_url()
);
}
public function test_repo_browse_url_returns_input(): void {
$this->assertSame(
'https://github.com/owner/repo',
$this->make()->get_repo_browse_url()
);
}
public function test_http_failure_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( new \WP_Error( 'http_error', 'boom' ) );
$this->assertNull( $this->make()->get_repo_data() );
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
require_once __DIR__ . '/../admin/metabox.php';
/**
* @covers \projects_portfolio_save_meta_box
*/
class Metabox_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
$_POST = [];
\Brain\Monkey\tearDown();
}
/** @var array<string,string> */
private array $saved = [];
protected function setUpSaveStubs(): void {
$this->saved = [];
\Brain\Monkey\Functions\stubs( [
'update_post_meta' => function ( $post_id, $key, $value ) {
$this->saved[ $key ] = $value;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
}
public function test_save_persists_provider_and_repo_url(): void {
$this->setUpSaveStubs();
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_provider'] = 'gitea';
$_POST['projects_portfolio_repo_url'] = 'https://codeberg.org/owner/repo';
$_POST['projects_portfolio_gitea_base_url'] = 'https://codeberg.org';
projects_portfolio_save_meta_box( 42 );
$this->assertSame( 'gitea', $this->saved['_projects_portfolio_provider'] );
$this->assertSame( 'https://codeberg.org/owner/repo', $this->saved['_projects_portfolio_repo_url'] );
$this->assertSame( 'https://codeberg.org', $this->saved['_projects_portfolio_gitea_base_url'] );
}
public function test_lazy_migration_from_legacy_url(): void {
$existing = [
'_projects_portfolio_provider' => '',
'_projects_portfolio_repo_url' => '',
'_projects_portfolio_github_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;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
// No $_POST provider/repo_url. Lazy migration should fire.
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
projects_portfolio_save_meta_box( 1 );
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
$this->assertSame( 'https://github.com/owner/repo', $existing['_projects_portfolio_repo_url'] );
}
public function test_switching_to_gitea_deletes_legacy_github_url(): 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_provider'] = 'gitea';
$_POST['projects_portfolio_repo_url'] = 'https://codeberg.org/owner/repo';
projects_portfolio_save_meta_box( 7 );
$this->assertSame( 'gitea', $existing['_projects_portfolio_provider'] );
$this->assertSame( 'https://codeberg.org/owner/repo', $existing['_projects_portfolio_repo_url'] );
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
public function test_github_with_empty_repo_url_deletes_legacy_github_url(): 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_provider'] = 'github';
$_POST['projects_portfolio_repo_url'] = '';
projects_portfolio_save_meta_box( 9 );
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
$this->assertSame( '', $existing['_projects_portfolio_repo_url'] );
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-github-provider.php';
require_once __DIR__ . '/../includes/providers/class-gitea-provider.php';
require_once __DIR__ . '/../includes/providers/class-provider-factory.php';
/**
* @covers \projects_portfolio_get_provider
*/
class Provider_Factory_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function stub_meta( array $provider_meta, array $options = [] ): void {
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( $provider_meta ) {
return $provider_meta[ $key ] ?? '';
},
'get_option' => function ( $key, $default = '' ) use ( $options ) {
return $options[ $key ] ?? $default;
},
] );
}
public function test_defaults_to_github_when_provider_meta_missing(): void {
$this->stub_meta( [
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
] );
$provider = projects_portfolio_get_provider( 42 );
$this->assertSame( 'github', $provider->get_id() );
$this->assertSame( 'https://github.com/owner/repo', $provider->get_repo_browse_url() );
}
public function test_uses_repo_url_when_provider_explicit(): void {
$this->stub_meta( [
'_projects_portfolio_provider' => 'gitea',
'_projects_portfolio_repo_url' => 'https://codeberg.org/owner/repo',
], [
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
'projects_portfolio_gitea_api_token' => 'gitea-token',
] );
$provider = projects_portfolio_get_provider( 7 );
$this->assertSame( 'gitea', $provider->get_id() );
}
public function test_per_project_gitea_base_overrides_global(): void {
$this->stub_meta( [
'_projects_portfolio_provider' => 'gitea',
'_projects_portfolio_repo_url' => 'https://git.example.org/owner/repo',
'_projects_portfolio_gitea_base_url' => 'https://git.example.org',
], [
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
] );
$provider = projects_portfolio_get_provider( 7 );
$this->assertSame( 'https://git.example.org/owner/repo', $provider->get_repo_browse_url() );
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
require_once __DIR__ . '/../includes/helper-functions.php';
/**
* @covers \projects_portfolio_settings
*/
class Settings_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
public function test_settings_aggregator_includes_gitea_keys(): void {
\Brain\Monkey\Functions\stubs( [
'get_option' => function ( $key, $default = '' ) {
$values = [
'projects_portfolio_default_gitea_base_url' => 'https://git.example.org',
'projects_portfolio_gitea_api_token' => 'secret',
];
return $values[ $key ] ?? $default;
},
] );
$settings = projects_portfolio_settings();
$this->assertSame( 'https://git.example.org', $settings['default_gitea_base_url'] );
$this->assertSame( 'secret', $settings['gitea_api_token'] );
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Brain\Monkey\Actions {
if ( ! function_exists( __NAMESPACE__ . '\\anyArgs' ) && function_exists( 'anyArgs' ) ) {
function anyArgs( ...$args ) { return \anyArgs( ...$args ); }
}
}
namespace {
// Shim: ensure Mockery's anyArgs() helper is loaded.
if ( ! function_exists( 'anyArgs' ) ) {
require_once dirname( __DIR__ ) . '/vendor/mockery/mockery/library/helpers.php';
}
// Define WP constants the helpers rely on.
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
define( 'HOUR_IN_SECONDS', 3600 );
}
if ( ! defined( 'WPINC' ) ) {
define( 'WPINC', 'wp-includes' );
}
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
// Provide minimal WordPress function stubs used by the providers under test.
if ( ! function_exists( 'get_option' ) ) {
function get_option( $key, $default = false ) { return $default; }
}
if ( ! function_exists( 'get_transient' ) ) {
function get_transient( $key ) { return false; }
}
if ( ! function_exists( 'set_transient' ) ) {
function set_transient( $key, $value, $expiration ) { return true; }
}
if ( ! function_exists( 'delete_transient' ) ) {
function delete_transient( $key ) { return true; }
}
if ( ! function_exists( 'wp_remote_get' ) ) {
function wp_remote_get( $url, $args = [] ) { return new \WP_Error( 'no_http', 'no http' ); }
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ) { return $thing instanceof \WP_Error; }
}
if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
function wp_remote_retrieve_response_code( $response ) {
if ( $response instanceof \WP_Error ) { return 0; }
return $response['response']['code'] ?? 0;
}
}
if ( ! function_exists( 'wp_remote_retrieve_body' ) ) {
function wp_remote_retrieve_body( $response ) {
if ( $response instanceof \WP_Error ) { return ''; }
return $response['body'] ?? '';
}
}
if ( ! function_exists( 'wp_remote_retrieve_header' ) ) {
function wp_remote_retrieve_header( $response, $header ) {
if ( $response instanceof \WP_Error ) { return ''; }
$headers = $response['headers'] ?? [];
$key = strtolower( $header );
return $headers[ $key ] ?? '';
}
}
if ( ! function_exists( 'error_log' ) ) {
function error_log( $message ) { /* swallow during tests */ }
}
if ( ! function_exists( 'get_post_meta' ) ) {
function get_post_meta( $post_id, $key = '', $single = false ) { return ''; }
}
if ( ! function_exists( 'update_post_meta' ) ) {
function update_post_meta( $post_id, $key, $value ) { return true; }
}
if ( ! function_exists( 'delete_post_meta' ) ) {
function delete_post_meta( $post_id, $key ) { return true; }
}
if ( ! function_exists( 'wp_unslash' ) ) {
function wp_unslash( $value ) { return $value; }
}
if ( ! function_exists( 'esc_url_raw' ) ) {
function esc_url_raw( $url ) { return $url; }
}
// Minimal WP_Error stub if Brain\Monkey doesn't supply one.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private $code;
private $message;
public function __construct( $code = '', $message = '' ) {
$this->code = $code;
$this->message = $message;
}
public function get_error_code() { return $this->code; }
public function get_error_message() { return $this->message; }
}
}
}