Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a0b0c808 | ||
|
|
dc11cb6a72 | ||
|
|
4d86db868c | ||
|
|
9b6aa8a6e8 | ||
|
|
9249a1e0a1 | ||
|
|
b7dfda1f99 | ||
|
|
93c81d0fa6 | ||
|
|
e9b55bb0e1 | ||
|
|
426d60b692 | ||
|
|
60db82e459 | ||
|
|
c10866bffd | ||
|
|
c127a1d065 | ||
|
|
cd5405fe62 | ||
|
|
98fd2fba5a | ||
|
|
5c843dc76f | ||
|
|
37832b72da | ||
|
|
aab39f66af |
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="Coding Style Checks">
|
||||
<description>Coding Style Checks.</description>
|
||||
<ini name="error_reporting" value="E_ALL & ~E_DEPRECATED" />
|
||||
|
||||
<arg value="sp"/>
|
||||
<arg name="colors"/>
|
||||
<arg name="extensions" value="php,html"/>
|
||||
<arg name="parallel" value="2048"/>
|
||||
|
||||
<exclude-pattern>vendor/</exclude-pattern>
|
||||
<exclude-pattern>node_modules/</exclude-pattern>
|
||||
|
||||
<rule ref="WordPress">
|
||||
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
|
||||
<exclude name="Generic.Functions.CallTimePassByReference"/>
|
||||
<exclude name="Universal.WhiteSpace.PrecisionAlignment.Found"/>
|
||||
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralText"/>
|
||||
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralDomain"/>
|
||||
<exclude name="WordPress.WP.I18n.NoEmptyStrings"/>
|
||||
<exclude name="WordPress.WP.EnqueuedResourceParameters.MissingVersion"/>
|
||||
<exclude name="WordPress.PHP.YodaConditions.NotYoda"/>
|
||||
<exclude name="WordPress.PHP.NoSilencedErrors.Discouraged"/>
|
||||
<exclude name="WordPress.Files.FileName.NotHyphenatedLowercase"/>
|
||||
<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidHookName.NotLowercase"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidHookName.UseUnderscores"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid"/>
|
||||
<exclude name="WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid"/>
|
||||
<exclude name="WordPress.DB.SlowDBQuery.slow_db_query_tax_query"/>
|
||||
<exclude name="WordPress.DB.SlowDBQuery.slow_db_query_meta_query"/>
|
||||
<exclude name="WordPress.Security.NonceVerification.Recommended"/>
|
||||
<exclude name="Squiz.Commenting.FileComment.MissingPackageTag"/>
|
||||
<exclude name="Squiz.Commenting.FileComment.Missing"/>
|
||||
<exclude name="Squiz.Commenting.FileComment.WrongStyle"/>
|
||||
<exclude name="Squiz.Commenting.InlineComment.InvalidEndChar"/>
|
||||
</rule>
|
||||
|
||||
<rule ref="Internal.NoCodeFound">
|
||||
<severity>0</severity>
|
||||
</rule>
|
||||
</ruleset>
|
||||
@@ -0,0 +1,314 @@
|
||||
# Projects Portfolio — Design
|
||||
|
||||
A developer-first showcase directory for WordPress plugins, themes, and
|
||||
patterns. Each "Project" post links to a GitHub or Gitea repository; the
|
||||
plugin pulls repo metadata (version, stars, forks, license, language,
|
||||
release ZIP) on demand and renders a per-project detail page.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
- A `projects` custom post type and `project-type` taxonomy — a WordPress-native showcase.
|
||||
- A metabox on each project that captures a Provider (GitHub or Gitea), the repo URL, and (for Gitea) a per-project base URL override.
|
||||
- A normalized data model so templates and the REST endpoint never branch on the underlying host.
|
||||
- A `/download/{id}/` endpoint that redirects to the release ZIP on the upstream host and increments a local counter.
|
||||
- Gitea parity with GitHub for the common metadata fields (stars, forks, issues, license, language, version) — with provider-agnostic display.
|
||||
- A settings page that exposes a Gitea base URL + token, template toggle fields, and archive toggle fields.
|
||||
- Works on both Gitea Actions and GitHub Actions runners (single workflow file with host-branching).
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- Provider-agnostic **write** operations (no plugin editing Gitea or GitHub on the user's behalf).
|
||||
- Multi-repo rendering on a single Project post (one project = one repo).
|
||||
- Cross-provider migration tools — adding a new project is per-provider manual.
|
||||
- WordPress.org plugin distribution (the Gitea release zip is the deliverable).
|
||||
- Browsing Gitea organizations or selecting from a list — the repo URL is user-provided.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Layered model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Templates + REST API + Download Redirect │
|
||||
│ (consume the interface, no provider logic) │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Provider factory: │
|
||||
│ projects_portfolio_get_provider($post_id)│
|
||||
│ reads post meta, returns adapter │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
GitHub_Provider Gitea_Provider
|
||||
(implements (implements
|
||||
Repository_Provider) Repository_Provider)
|
||||
│ │
|
||||
└────────────┬────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ WP HTTP API + transient cache │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 File layout
|
||||
|
||||
```
|
||||
projects-portfolio/
|
||||
├── projects-portfolio.php Plugin bootstrap; CPT/taxonomy wiring; rewrite
|
||||
│ rules; download redirect; REST registration;
|
||||
│ admin columns; social-share buttons
|
||||
├── composer.json / composer.lock Dev-only deps (PHPUnit, Brain\Monkey,
|
||||
│ yoast/phpunit-polyfills)
|
||||
├── phpunit.xml.dist PHPUnit configuration
|
||||
├── .phpcs.xml PHPCS coding-style ruleset
|
||||
├── README.md End-user documentation
|
||||
├── DESIGN.md This file
|
||||
│
|
||||
├── admin/
|
||||
│ ├── cpt-taxonomy.php `projects` CPT + `project-type` taxonomy
|
||||
│ ├── metabox.php Repository Provider / URL / Gitea base metabox
|
||||
│ ├── admin-settings.php Settings page (General + Gitea + Templates + Archives)
|
||||
│ └── rest-api.php REST route registration (if separated)
|
||||
│
|
||||
├── includes/
|
||||
│ ├── helper-functions.php Provider-aware wrappers, settings aggregator
|
||||
│ ├── plugin-update-checker/ Bundled PUC v5.7 (self-update notifications)
|
||||
│ └── providers/
|
||||
│ ├── interface-repository-provider.php Contract (7 methods)
|
||||
│ ├── class-github-provider.php GitHub adapter (preserves v1.0 behavior)
|
||||
│ ├── class-gitea-provider.php Gitea adapter (self-hosted, configurable)
|
||||
│ └── class-provider-factory.php projects_portfolio_get_provider()
|
||||
│
|
||||
├── templates/
|
||||
│ ├── single-projects.php Per-project detail template
|
||||
│ ├── archive-projects.php CPT archive
|
||||
│ └── taxonomy-project-type.php Taxonomy archive
|
||||
│
|
||||
├── assets/
|
||||
│ ├── css/style.css Front-end styles
|
||||
│ ├── css/admin-styles.css Admin styles
|
||||
│ ├── js/buttons.js (Legacy — retained for compat, unused)
|
||||
│ └── icons/ Lucide brand-share SVG icons (Facebook, X,
|
||||
│ LinkedIn, Mail, Reddit, WhatsApp, Pinterest)
|
||||
│
|
||||
├── languages/ Translation files (.pot/.po/.mo/.l10n.php)
|
||||
│
|
||||
├── scripts/
|
||||
│ └── release-helper.py GHA workflow helper: build payload,
|
||||
│ extract id, extract upload_url
|
||||
│
|
||||
├── tests/
|
||||
│ ├── bootstrap.php Brain\Monkey bootstrap + WP function stubs
|
||||
│ ├── wp-stubs.php Shared WP function stubs (brain\monkey safe)
|
||||
│ ├── test-github-provider.php
|
||||
│ ├── test-gitea-provider.php
|
||||
│ ├── test-provider-factory.php
|
||||
│ ├── test-metabox.php
|
||||
│ └── test-settings.php
|
||||
│
|
||||
├── .github/workflows/
|
||||
│ └── release.yml Builds projects-portfolio-v<version>.zip on `v*`
|
||||
│ tag push; branches on host (Gitea Actions vs
|
||||
│ GitHub Actions).
|
||||
│
|
||||
├── specs/ Design specs (Gitea-support, release-workflow)
|
||||
├── plans/ Implementation plans
|
||||
└── docs/superpowers/ Skill workflow artifacts
|
||||
```
|
||||
|
||||
### 3.3 Provider interface
|
||||
|
||||
```php
|
||||
interface Repository_Provider {
|
||||
public function get_id(): string; // 'github' | 'gitea'
|
||||
public function get_label(): string; // 'GitHub' | 'Gitea'
|
||||
public function get_repo_data(): ?array; // normalized shape (see §6)
|
||||
public function get_release_url(): ?string; // zip URL or null
|
||||
public function get_latest_version(): string; // tag name or 'Unknown'
|
||||
public function get_repo_browse_url(): string; // public repo URL
|
||||
public function get_owner_data( string $owner_login ): ?array; // avatar/login/html_url
|
||||
}
|
||||
```
|
||||
|
||||
Both adapters implement this contract. Templates, the REST endpoint, and the download handler never branch on provider — they only call the interface.
|
||||
|
||||
## 4. Per-project storage
|
||||
|
||||
Post meta keys (all `string` unless noted):
|
||||
|
||||
| Key | Type | Notes |
|
||||
|---|---|---|
|
||||
| `_projects_portfolio_provider` | `'github'` \| `'gitea'` | Defaults to `'github'` when missing. |
|
||||
| `_projects_portfolio_repo_url` | string (URL) | Canonical repo URL. Falls back to legacy `_projects_portfolio_github_url` when empty. |
|
||||
| `_projects_portfolio_gitea_base_url` | string (URL) | Per-project Gitea override. Falls back to the global option when empty. |
|
||||
| `_projects_portfolio_github_url` | string (URL) | **Deprecated, read-only.** Retained for backward compatibility. Cleared when the project is saved with provider = `gitea`, or when provider = `github` and the new `repo_url` is empty. |
|
||||
| `_projects_portfolio_download_count` | int | Incremented locally by the download endpoint. |
|
||||
|
||||
### 4.1 Lazy migration
|
||||
|
||||
The metabox save handler performs a one-time lazy migration:
|
||||
|
||||
```
|
||||
on save:
|
||||
if (provider meta empty AND repo_url empty AND legacy github_url non-empty):
|
||||
set provider = 'github'
|
||||
set repo_url = legacy github_url
|
||||
if (provider meta == 'gitea'):
|
||||
delete legacy github_url
|
||||
if (provider meta == 'github' AND new repo_url is empty):
|
||||
delete legacy github_url
|
||||
```
|
||||
|
||||
Existing projects that were configured before the Gitea support was added keep working without any data-migration step.
|
||||
|
||||
## 5. Global settings
|
||||
|
||||
Options (all registered via the WP Options API):
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `projects_portfolio_github_api_token` | `''` | Bearer token for authenticated GitHub requests. |
|
||||
| `projects_portfolio_gitea_api_token` | `''` | Bearer token for authenticated Gitea requests. |
|
||||
| `projects_portfolio_default_gitea_base_url` | `'https://codeberg.org'` | Used when a Gitea project has no per-project override. |
|
||||
| `projects_portfolio_share_telemetry` | `'0'` | Reserved for future telemetry. |
|
||||
| `projects_portfolio_templates_<key>` | `'0'` | One per template field (version, last_updated, license, language, downloads, forks, stargazers_count, open_issues_count, github_owner). |
|
||||
| `projects_portfolio_archives_<key>` | `'0'` | One per archive field (archive_title, project_title, project_excerpt, project_buttons). |
|
||||
|
||||
`projects_portfolio_settings()` aggregates all of the above into a single array (cached in a function-local static for the request) and is consumed by templates.
|
||||
|
||||
## 6. Normalized repo data shape
|
||||
|
||||
Both adapters return the same shape from `get_repo_data()`. The GitHub adapter returns it natively; the Gitea adapter maps its native fields.
|
||||
|
||||
```php
|
||||
[
|
||||
'owner' => [
|
||||
'avatar_url' => string,
|
||||
'login' => string,
|
||||
'html_url' => string,
|
||||
],
|
||||
'updated_at' => string, // ISO 8601
|
||||
'language' => string,
|
||||
'license' => [ 'name' => string ], // 'None' if unset
|
||||
'stargazers_count' => int, // mapped from `stars_count` for Gitea
|
||||
'forks_count' => int,
|
||||
'open_issues_count' => int,
|
||||
]
|
||||
```
|
||||
|
||||
### 6.1 Gitea → normalized mapping
|
||||
|
||||
| Gitea field | Normalized field |
|
||||
|---|---|
|
||||
| `stars_count` | `stargazers_count` |
|
||||
| `forks_count` | `forks_count` |
|
||||
| `open_issues_count` | `open_issues_count` |
|
||||
| `updated_at` | `updated_at` |
|
||||
| `language` | `language` |
|
||||
| `license` (string \| null \| object) | `license.name` ('None' if unset) |
|
||||
| `owner.avatar_url` | `owner.avatar_url` |
|
||||
| `owner.login` | `owner.login` |
|
||||
| `owner.html_url` | `owner.html_url` (falls back to `{base}/{owner}`) |
|
||||
|
||||
## 7. Endpoints
|
||||
|
||||
### 7.1 GitHub
|
||||
|
||||
| Purpose | URL |
|
||||
|---|---|
|
||||
| Repo metadata | `https://api.github.com/repos/{owner}/{repo}` |
|
||||
| Latest release | `…/{owner}/{repo}/releases/latest` |
|
||||
| Owner profile | `https://api.github.com/users/{login}` |
|
||||
|
||||
### 7.2 Gitea
|
||||
|
||||
| Purpose | URL |
|
||||
|---|---|
|
||||
| Repo metadata | `{base}/api/v1/repos/{owner}/{repo}` |
|
||||
| Latest release | `{base}/api/v1/repos/{owner}/{repo}/releases/latest` (returns a single object) |
|
||||
| Owner profile | `{base}/api/v1/users/{login}` |
|
||||
| Archive fallback | `{base}/{owner}/{repo}/archive/refs/tags/{tag}.zip` |
|
||||
|
||||
The Gitea release endpoint returns a **single release object**, not a list (unlike GitHub's). The adapter detects shape with `isset($body['tag_name'])` and treats it as a release object directly.
|
||||
|
||||
### 7.3 Authentication
|
||||
|
||||
- GitHub: header `Authorization: token <PAT>`.
|
||||
- Gitea: header `Authorization: token <PAT>`. The PAT is stored in `projects_portfolio_gitea_api_token` and supplied via `GITEA_TOKEN` repo secret on the release runner.
|
||||
|
||||
### 7.4 Plugin-side endpoints
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `/download/{id}/` | Custom rewrite rule. Resolves to `index.php?project_download_id=<id>`. Handler at `template_redirect`: looks up the project's release URL via the provider, increments `_projects_portfolio_download_count`, then `wp_redirect()` to the upstream URL. Uses `wp_redirect()` (not `wp_safe_redirect()`) because the destination is a user-configured external host — `wp_safe_redirect()` would reject it and fall back to `admin_url()`. |
|
||||
| `/wp-json/projects/v1/projects` | Lists all projects with their normalized metadata and download URL. Backward-compatible field names (`github_url`, `github_data`) plus a sibling `provider` field. |
|
||||
|
||||
## 8. Provider interface — method semantics
|
||||
|
||||
| Method | GitHub | Gitea |
|
||||
|---|---|---|
|
||||
| `get_id()` | `'github'` | `'gitea'` |
|
||||
| `get_label()` | `'GitHub'` | `'Gitea'` |
|
||||
| `get_repo_data()` | Returns the GitHub repo object directly (already matches the normalized shape). | Maps `stars_count → stargazers_count`, normalizes `license` to `{name: string}`, returns null on non-200. |
|
||||
| `get_release_url()` | Iterates `assets[]` for a `.zip` entry; falls back to `zipball_url`. | Iterates the single release object's `assets[]`; falls back to `{base}/{owner}/{repo}/archive/refs/tags/{tag}.zip`. |
|
||||
| `get_latest_version()` | Returns `tag_name`. | Same. |
|
||||
| `get_repo_browse_url()` | Returns the user-supplied URL. | Returns `{base}/{owner_repo_path}`. |
|
||||
| `get_owner_data($login)` | GET `/users/{login}` via `file_get_contents` (legacy). | GET `{base}/api/v1/users/{login}` via `wp_remote_get`. |
|
||||
|
||||
## 9. Template layer
|
||||
|
||||
The template engine uses the standard WordPress template-hierarchy lookup. The plugin registers `single-projects.php`, `archive-projects.php`, and `taxonomy-project-type.php` in its own `templates/` directory. Theme authors may override by copying any of these into their theme root.
|
||||
|
||||
The single-projects template:
|
||||
1. Calls `projects_portfolio_get_repo_data($post_id)` once and reuses the result for the whole page.
|
||||
2. Renders the buttons (Download, View Repo), the metadata table, the project-owner block, and the social-share action via `do_action('projects_after_download_button', $post_id)`.
|
||||
3. Uses `background-image` (not `<img>`) for the owner avatar, with explicit pixel sizing (`width: 40px; height: 40px; background-size: 40px 40px`) because on some hosting environments the `cover` keyword for `background-size` is silently dropped and falls back to `auto auto` — which produces a tiny top-left-anchored rendering instead of a centered cover crop. Pixel values avoid that quirk.
|
||||
|
||||
## 10. Release workflow
|
||||
|
||||
`.github/workflows/release.yml` triggers on `v*` tag push. On `ubuntu-latest` it:
|
||||
|
||||
1. Checks out the repo at the tag's commit.
|
||||
2. Builds the plugin zip via an embedded Python script (`zipfile` stdlib — `zip` CLI is not guaranteed on Gitea Actions runners).
|
||||
3. Uploads as workflow artifact.
|
||||
4. Creates or updates a release via the host's REST API:
|
||||
- GitHub.com path: POST `https://api.github.com/repos/{owner}/{repo}/releases` using `secrets.GITHUB_TOKEN`, then upload asset via the per-release `upload_url` template.
|
||||
- Gitea path: POST `{api}/v1/repos/{owner}/{repo}/releases` using `secrets.GITEA_TOKEN`, then POST the asset to `{api}/v1/repos/{owner}/{repo}/releases/{id}/assets`.
|
||||
|
||||
Both paths first **look up an existing release by tag** (idempotent on re-run), reusing its id if present. The asset upload uses `|| echo "::warning::Asset upload failed (asset may already exist); continuing."` so duplicate-asset 409s don't fail the run.
|
||||
|
||||
## 11. Plugin update checker (self-update)
|
||||
|
||||
The plugin ships a bundled copy of [YahnisElsts/plugin-update-checker](https://github.com/YahnisElsts/plugin-update-checker) v5.7 (latest upstream as of May 2026) so the plugin can advertise in-plugin update notifications when newer tags are pushed to the repo. Because the repo URL is a Gitea instance (not github.com), PUC falls back to its plain-JSON metadata mode and the plugin skips calling `setBranch()` — that method only exists on the VCS-specific adapter (`YahnisElsts\PluginUpdateChecker\v5p4\Vcs\PluginUpdateChecker` via the `VcsCheckerMethods` trait), and calling it on the plain metadata adapter throws `Error: Call to undefined method ...`.
|
||||
|
||||
## 12. Testing
|
||||
|
||||
PHPUnit under `tests/`. The bootstrap loads Brain\Monkey and stubs the small set of WP functions the providers call (`wp_remote_get`, `wp_remote_retrieve_response_code`, `wp_remote_retrieve_body`, `wp_remote_retrieve_header`, `is_wp_error`, `set_transient`, `get_transient`, `update_post_meta`, `get_post_meta`, `delete_post_meta`, `wp_unslash`, `esc_url_raw`, `error_log`).
|
||||
|
||||
Coverage:
|
||||
- `test-github-provider.php`: URL construction, headers, `.zip` asset selection, `zipball_url` fallback, owner profile fetch.
|
||||
- `test-gitea-provider.php`: URL construction, header behavior, field mapping (`stars_count → stargazers_count`, license normalization for string/null/object), `.zip` asset selection, archive fallback, **release-object shape (single object, not array)**, empty array handling, self-hosted base URL.
|
||||
- `test-provider-factory.php`: provider selection from meta, legacy URL fallback, per-project Gitea base override.
|
||||
- `test-metabox.php`: save persistence, lazy migration, provider-switch clearing of legacy URL, empty-repo clearing of legacy URL.
|
||||
- `test-settings.php`: new option keys exposed via `projects_portfolio_settings()`.
|
||||
|
||||
## 13. i18n
|
||||
|
||||
Text domain `projects-wp`. Translation template at `languages/projects-wp.pot`; sibling `.po`/`.mo`/`.l10n.php` files for `es_MX` and `fr_FR`. All user-facing strings run through `esc_html_e()` / `esc_html__()` / `esc_attr_e()` / `esc_attr__()`.
|
||||
|
||||
## 14. Backward compatibility
|
||||
|
||||
- Legacy post meta key `_projects_portfolio_github_url` is read with a lazy fallback in the factory and the single-projects template.
|
||||
- Legacy GitHub-only helper functions (`projects_portfolio_get_github_data`, `projects_portfolio_get_github_release_url`, `projects_portfolio_get_version_from_github`, `projects_portfolio_github_owner`) are retained verbatim with `_deprecated_function()` notices. New code uses the provider-aware wrappers.
|
||||
- REST response shape retains the `github_url` and `github_data` field names (legacy consumers); adds a sibling `provider` field. New consumers should branch on `provider`.
|
||||
|
||||
## 15. Open follow-ups
|
||||
|
||||
- **In-tree version constant stays at the version that was first set for the Gitea-support release** (1.1.1) and has not been bumped with subsequent tags (1.1.2 → 1.1.14). The release workflow builds the zip from the tag name, but the plugin header reports the in-tree constant. Future releases should bump both at the same time. Tracked as a release-process gap, not a bug.
|
||||
- **CSS cover keyword quirk** is currently worked around by using pixel values; future improvement is to investigate the host where `cover` falls back to `auto auto` and report upstream.
|
||||
- **The bundled Lucide icons are a curated subset** (7 social networks). If a new network is added to the share buttons, drop a new `<name>.svg` into `assets/icons/` and add a `$svg_icon()` call in `projects_portfolio.php`.
|
||||
@@ -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,7 +42,7 @@ 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, choose **Provider** (GitHub or Gitea) and paste the **Repository URL** (e.g., `https://github.com/username/repo` or `https://codeberg.org/username/repo`).
|
||||
@@ -79,7 +79,7 @@ Enable/disable the following GitHub data in your project views:
|
||||
- Language
|
||||
- Downloads
|
||||
- Stars / Forks / Issues
|
||||
- GitHub Owner
|
||||
- Repo Owner
|
||||
|
||||
### Archive Settings
|
||||
|
||||
@@ -94,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
|
||||
|
||||
@@ -194,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).
|
||||
|
||||
@@ -33,6 +33,7 @@ add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' );
|
||||
function projects_portfolio_render_meta_box( $post ) {
|
||||
wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' );
|
||||
|
||||
$is_website = (bool) get_post_meta( $post->ID, '_projects_portfolio_is_website', true );
|
||||
$provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
|
||||
if ( '' === $provider ) {
|
||||
$provider = 'github';
|
||||
@@ -42,7 +43,19 @@ function projects_portfolio_render_meta_box( $post ) {
|
||||
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
|
||||
}
|
||||
$gitea_base_url = get_post_meta( $post->ID, '_projects_portfolio_gitea_base_url', true );
|
||||
|
||||
$site_url = get_post_meta( $post->ID, '_projects_portfolio_site_url', true );
|
||||
$site_platform = get_post_meta( $post->ID, '_projects_portfolio_site_platform', true );
|
||||
$site_screenshot = (int) get_post_meta( $post->ID, '_projects_portfolio_site_screenshot_id', true );
|
||||
?>
|
||||
<p>
|
||||
<label>
|
||||
<input type="checkbox" id="projects_portfolio_is_website" name="projects_portfolio_is_website" value="1" <?php checked( $is_website, true ); ?> />
|
||||
<?php esc_html_e( 'This project is a website', 'projects-wp' ); ?>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<div class="projects-portfolio-repo-fields" <?php echo $is_website ? 'style="display:none;"' : ''; ?>>
|
||||
<p>
|
||||
<label for="projects_portfolio_provider"><?php esc_html_e( 'Provider', 'projects-wp' ); ?></label>
|
||||
<select id="projects_portfolio_provider" name="projects_portfolio_provider" style="width: 100%;">
|
||||
@@ -59,22 +72,110 @@ function projects_portfolio_render_meta_box( $post ) {
|
||||
<input type="url" id="projects_portfolio_gitea_base_url" name="projects_portfolio_gitea_base_url" value="<?php echo esc_attr( $gitea_base_url ); ?>" style="width: 100%;" placeholder="https://codeberg.org" />
|
||||
<span class="description"><?php esc_html_e( 'Leave blank to use the default from Settings.', 'projects-wp' ); ?></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="projects-portfolio-website-fields"<?php echo $is_website ? '' : ' style="display:none;"'; ?>>
|
||||
<p>
|
||||
<label for="projects_portfolio_site_url"><?php esc_html_e( 'Site URL', 'projects-wp' ); ?></label>
|
||||
<input type="url" id="projects_portfolio_site_url" name="projects_portfolio_site_url" value="<?php echo esc_attr( $site_url ); ?>" style="width: 100%;" placeholder="https://example.com" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="projects_portfolio_site_platform"><?php esc_html_e( 'Platform', 'projects-wp' ); ?></label>
|
||||
<textarea id="projects_portfolio_site_platform" name="projects_portfolio_site_platform" rows="3" style="width: 100%;" placeholder="<?php esc_attr_e( 'e.g. WordPress, custom Next.js, etc.', 'projects-wp' ); ?>"><?php echo esc_textarea( $site_platform ); ?></textarea>
|
||||
</p>
|
||||
<p>
|
||||
<label for="projects_portfolio_site_screenshot_id"><?php esc_html_e( 'Screenshot (optional)', 'projects-wp' ); ?></label>
|
||||
<span class="projects-portfolio-screenshot-preview" style="<?php echo $site_screenshot ? '' : 'display:none;'; ?> margin-right: 8px;">
|
||||
<?php if ( $site_screenshot ) : ?>
|
||||
<?php echo wp_get_attachment_image( $site_screenshot, 'thumbnail' ); // phpcs:ignore ?>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<input type="hidden" id="projects_portfolio_site_screenshot_id" name="projects_portfolio_site_screenshot_id" value="<?php echo esc_attr( $site_screenshot ); ?>" />
|
||||
<button type="button" class="button projects-portfolio-screenshot-select"><?php esc_html_e( 'Select / Upload', 'projects-wp' ); ?></button>
|
||||
<button type="button" class="button projects-portfolio-screenshot-clear" <?php echo $site_screenshot ? '' : 'style="display:none;"'; ?>><?php esc_html_e( 'Remove', 'projects-wp' ); ?></button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var sel = document.getElementById('projects_portfolio_provider');
|
||||
if (!sel) return;
|
||||
if (sel) {
|
||||
var gitea = document.querySelector('.projects-portfolio-gitea-only');
|
||||
function toggle(){
|
||||
function toggleGitea(){
|
||||
if (!gitea) return;
|
||||
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
|
||||
}
|
||||
sel.addEventListener('change', toggle);
|
||||
toggle();
|
||||
sel.addEventListener('change', toggleGitea);
|
||||
toggleGitea();
|
||||
}
|
||||
|
||||
var toggle = document.getElementById('projects_portfolio_is_website');
|
||||
if (toggle) {
|
||||
var repoBox = document.querySelector('.projects-portfolio-repo-fields');
|
||||
var siteBox = document.querySelector('.projects-portfolio-website-fields');
|
||||
function toggleMode(){
|
||||
var on = !!toggle.checked;
|
||||
if (repoBox) repoBox.style.display = on ? 'none' : '';
|
||||
if (siteBox) siteBox.style.display = on ? '' : 'none';
|
||||
}
|
||||
toggle.addEventListener('change', toggleMode);
|
||||
toggleMode();
|
||||
}
|
||||
|
||||
var frame;
|
||||
var selectBtn = document.querySelector('.projects-portfolio-screenshot-select');
|
||||
var clearBtn = document.querySelector('.projects-portfolio-screenshot-clear');
|
||||
var inputEl = document.getElementById('projects_portfolio_site_screenshot_id');
|
||||
var previewEl = document.querySelector('.projects-portfolio-screenshot-preview');
|
||||
function openFrame(){
|
||||
if (typeof wp === 'undefined' || !wp.media) { return; }
|
||||
frame = wp.media({
|
||||
title: '<?php echo esc_js( __( 'Select Screenshot', 'projects-wp' ) ); ?>',
|
||||
button: { text: '<?php echo esc_js( __( 'Use this image', 'projects-wp' ) ); ?>' },
|
||||
library: { type: 'image' },
|
||||
multiple: false
|
||||
});
|
||||
frame.on('select', function(){
|
||||
var attachment = frame.state().get('selection').first().toJSON();
|
||||
inputEl.value = attachment.id;
|
||||
previewEl.innerHTML = '<img src="' + attachment.sizes && attachment.sizes.thumbnail ? attachment.sizes.thumbnail.url : attachment.url + '" alt="" />';
|
||||
previewEl.style.display = '';
|
||||
clearBtn.style.display = '';
|
||||
});
|
||||
frame.open();
|
||||
}
|
||||
if (selectBtn) { selectBtn.addEventListener('click', openFrame); }
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function(){
|
||||
inputEl.value = '';
|
||||
previewEl.innerHTML = '';
|
||||
previewEl.style.display = 'none';
|
||||
clearBtn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue media picker assets for the project meta box.
|
||||
*
|
||||
* @since 1.4.0
|
||||
* @return void
|
||||
*/
|
||||
function projects_portfolio_enqueue_media_for_meta_box( $hook ) {
|
||||
if ( ! in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) {
|
||||
return;
|
||||
}
|
||||
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||
if ( ! $screen || 'projects' !== $screen->post_type ) {
|
||||
return;
|
||||
}
|
||||
wp_enqueue_media();
|
||||
}
|
||||
add_action( 'admin_enqueue_scripts', 'projects_portfolio_enqueue_media_for_meta_box' );
|
||||
|
||||
/**
|
||||
* Save the Repository meta box.
|
||||
*
|
||||
@@ -103,6 +204,14 @@ function projects_portfolio_save_meta_box( $post_id ) {
|
||||
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
|
||||
}
|
||||
|
||||
// "This project is a website" toggle — drives which set of fields is kept.
|
||||
$is_website = isset( $_POST['projects_portfolio_is_website'] ) && '1' === $_POST['projects_portfolio_is_website'];
|
||||
if ( $is_website ) {
|
||||
update_post_meta( $post_id, '_projects_portfolio_is_website', '1' );
|
||||
} else {
|
||||
delete_post_meta( $post_id, '_projects_portfolio_is_website' );
|
||||
}
|
||||
|
||||
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
|
||||
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
|
||||
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
|
||||
@@ -130,5 +239,43 @@ function projects_portfolio_save_meta_box( $post_id ) {
|
||||
if ( isset( $_POST['projects_portfolio_gitea_base_url'] ) ) {
|
||||
update_post_meta( $post_id, '_projects_portfolio_gitea_base_url', esc_url_raw( wp_unslash( $_POST['projects_portfolio_gitea_base_url'] ) ) );
|
||||
}
|
||||
|
||||
// Website fields.
|
||||
if ( isset( $_POST['projects_portfolio_site_url'] ) ) {
|
||||
$site_url = esc_url_raw( wp_unslash( $_POST['projects_portfolio_site_url'] ) );
|
||||
if ( '' === $site_url ) {
|
||||
delete_post_meta( $post_id, '_projects_portfolio_site_url' );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_projects_portfolio_site_url', $site_url );
|
||||
}
|
||||
}
|
||||
if ( isset( $_POST['projects_portfolio_site_platform'] ) ) {
|
||||
$platform = sanitize_text_field( wp_unslash( $_POST['projects_portfolio_site_platform'] ) );
|
||||
if ( '' === $platform ) {
|
||||
delete_post_meta( $post_id, '_projects_portfolio_site_platform' );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_projects_portfolio_site_platform', $platform );
|
||||
}
|
||||
}
|
||||
if ( isset( $_POST['projects_portfolio_site_screenshot_id'] ) ) {
|
||||
$screenshot_id = absint( wp_unslash( $_POST['projects_portfolio_site_screenshot_id'] ) );
|
||||
if ( $screenshot_id <= 0 ) {
|
||||
delete_post_meta( $post_id, '_projects_portfolio_site_screenshot_id' );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_projects_portfolio_site_screenshot_id', $screenshot_id );
|
||||
}
|
||||
}
|
||||
|
||||
// When modes switch, clear the opposite mode's fields so the project is cleanly one or the other.
|
||||
if ( $is_website ) {
|
||||
delete_post_meta( $post_id, '_projects_portfolio_provider' );
|
||||
delete_post_meta( $post_id, '_projects_portfolio_repo_url' );
|
||||
delete_post_meta( $post_id, '_projects_portfolio_gitea_base_url' );
|
||||
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
|
||||
} else {
|
||||
delete_post_meta( $post_id, '_projects_portfolio_site_url' );
|
||||
delete_post_meta( $post_id, '_projects_portfolio_site_platform' );
|
||||
delete_post_meta( $post_id, '_projects_portfolio_site_screenshot_id' );
|
||||
}
|
||||
}
|
||||
add_action( 'save_post', 'projects_portfolio_save_meta_box' );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -104,6 +104,56 @@ function projects_portfolio_get_repo_browse_url( int $post_id ): string {
|
||||
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this project is configured as a website rather than a git repo.
|
||||
*
|
||||
* @since 1.4.0
|
||||
* @param int $post_id
|
||||
* @return bool
|
||||
*/
|
||||
function projects_portfolio_is_website( int $post_id ): bool {
|
||||
return (bool) get_post_meta( $post_id, '_projects_portfolio_is_website', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Public URL for the project's website (when configured as a website).
|
||||
*
|
||||
* @since 1.4.0
|
||||
* @param int $post_id
|
||||
* @return string
|
||||
*/
|
||||
function projects_portfolio_get_site_url( int $post_id ): string {
|
||||
return (string) get_post_meta( $post_id, '_projects_portfolio_site_url', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform label for the project's website (when configured as a website).
|
||||
*
|
||||
* @since 1.4.0
|
||||
* @param int $post_id
|
||||
* @return string
|
||||
*/
|
||||
function projects_portfolio_get_site_platform( int $post_id ): string {
|
||||
return (string) get_post_meta( $post_id, '_projects_portfolio_site_platform', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshot attachment URL for the project's website.
|
||||
*
|
||||
* @since 1.4.0
|
||||
* @param int $post_id
|
||||
* @param string $size
|
||||
* @return string
|
||||
*/
|
||||
function projects_portfolio_get_site_screenshot_url( int $post_id, string $size = 'large' ): string {
|
||||
$attachment_id = (int) get_post_meta( $post_id, '_projects_portfolio_site_screenshot_id', true );
|
||||
if ( $attachment_id <= 0 ) {
|
||||
return '';
|
||||
}
|
||||
$src = wp_get_attachment_image_src( $attachment_id, $size );
|
||||
return $src[0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub API call for owner data
|
||||
*
|
||||
|
||||
@@ -186,7 +186,13 @@ class Gitea_Provider implements Repository_Provider {
|
||||
return null;
|
||||
}
|
||||
|
||||
$release = $body[0];
|
||||
// Gitea's /releases/latest returns a single release OBJECT (associative array),
|
||||
// not a list. Some endpoints / older Gitea versions may return a list; handle both.
|
||||
$release = isset( $body['tag_name'] ) ? $body : ( $body[0] ?? null );
|
||||
if ( ! is_array( $release ) || empty( $release['tag_name'] ?? '' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
set_transient( $cache_key, $release, HOUR_IN_SECONDS );
|
||||
return $release;
|
||||
}
|
||||
|
||||
@@ -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.1.1
|
||||
* Version: 1.2.0
|
||||
* Author: Keith Solomon
|
||||
* Author URI: https://keithsolomon.net
|
||||
* License: GPL-2.0+
|
||||
@@ -39,7 +39,7 @@ $myUpdateChecker = PucFactory::buildUpdateChecker(
|
||||
// PUC's plain JSON metadata mode, so we skip the call rather than throw.
|
||||
|
||||
// Current plugin version.
|
||||
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );
|
||||
define( 'PROJECTS_PORTFOLIO_VERSION', '1.2.0' );
|
||||
|
||||
// Add the required files.
|
||||
require 'admin/admin-settings.php';
|
||||
@@ -112,7 +112,10 @@ function projects_portfolio_handle_download_redirect() {
|
||||
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
|
||||
|
||||
// Redirect to the release ZIP on the project's host.
|
||||
wp_safe_redirect( esc_url_raw( $download_url ) );
|
||||
// Use wp_redirect() (not wp_safe_redirect()) because the destination
|
||||
// is an external host the user has explicitly configured — wp_safe_redirect
|
||||
// would reject it and fall back to admin_url().
|
||||
wp_redirect( esc_url_raw( $download_url ), 302 ); // phpcs:ignore
|
||||
exit;
|
||||
} else {
|
||||
wp_die(
|
||||
@@ -268,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' );
|
||||
|
||||
@@ -351,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>';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -13,23 +13,31 @@ $settings = projects_portfolio_settings();
|
||||
$project_id = get_the_ID();
|
||||
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
|
||||
|
||||
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
|
||||
if ( '' === $repo_url ) {
|
||||
$is_website = projects_portfolio_is_website( $project_id );
|
||||
|
||||
if ( $is_website ) {
|
||||
$site_url = projects_portfolio_get_site_url( $project_id );
|
||||
$site_platform = projects_portfolio_get_site_platform( $project_id );
|
||||
$site_shot_url = projects_portfolio_get_site_screenshot_url( $project_id, 'large' );
|
||||
} else {
|
||||
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
|
||||
if ( '' === $repo_url ) {
|
||||
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
|
||||
}
|
||||
|
||||
$repo_data = projects_portfolio_get_repo_data( $project_id );
|
||||
$version = projects_portfolio_get_version( $project_id );
|
||||
$owner = projects_portfolio_get_owner( $project_id );
|
||||
|
||||
$owner_avatar = $repo_data['owner']['avatar_url'] ?? '';
|
||||
$owner_name = $repo_data['owner']['login'] ?? '';
|
||||
$owner_url = $repo_data['owner']['html_url'] ?? '';
|
||||
$last_updated = $repo_data['updated_at'] ?? '';
|
||||
$language = $repo_data['language'] ?? '';
|
||||
$license = $repo_data['license']['name'] ?? 'None';
|
||||
$browse_url = $repo_url ? projects_portfolio_get_repo_browse_url( $project_id ) : '';
|
||||
}
|
||||
|
||||
$repo_data = projects_portfolio_get_repo_data( $project_id );
|
||||
$version = projects_portfolio_get_version( $project_id );
|
||||
$owner = projects_portfolio_get_owner( $project_id );
|
||||
|
||||
$owner_avatar = $repo_data['owner']['avatar_url'] ?? '';
|
||||
$owner_name = $repo_data['owner']['login'] ?? '';
|
||||
$owner_url = $repo_data['owner']['html_url'] ?? '';
|
||||
$last_updated = $repo_data['updated_at'] ?? '';
|
||||
$language = $repo_data['language'] ?? '';
|
||||
$license = $repo_data['license']['name'] ?? 'None';
|
||||
$browse_url = $repo_url ? projects_portfolio_get_repo_browse_url( $project_id ) : '';
|
||||
|
||||
if ( $last_updated ) {
|
||||
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
|
||||
}
|
||||
@@ -49,6 +57,13 @@ if ( $last_updated ) {
|
||||
</div>
|
||||
|
||||
<div class="project-main-content">
|
||||
<?php if ( ! empty( $site_shot_url ) ) : ?>
|
||||
<div class="project-screenshot">
|
||||
<a href="<?php echo esc_url( $site_url ); ?>" target="_blank" rel="noopener noreferrer">
|
||||
<img src="<?php echo esc_url( $site_shot_url ); ?>" alt="<?php echo esc_attr( get_the_title() ); ?>" />
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="project-description">
|
||||
<h1 class="project-title">
|
||||
<?php
|
||||
@@ -68,13 +83,22 @@ if ( $last_updated ) {
|
||||
<a href="<?php echo esc_url( site_url( '/download/' . $project_id ) ); ?>" class="button project-download-button">
|
||||
<?php esc_html_e( 'Download Now', 'projects-wp' ); ?>
|
||||
</a>
|
||||
<?php if ( $repo_url ) : ?>
|
||||
<a href="<?php echo esc_url( $browse_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer">
|
||||
<?php if ( $is_website ) : ?>
|
||||
<?php if ( ! empty( $site_url ) ) : ?>
|
||||
<a href="<?php echo esc_url( $site_url ); ?>" class="button project-repo-button" target="_blank" rel="noopener noreferrer">
|
||||
<?php esc_html_e( 'Visit Site', 'projects-wp' ); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e( 'Website:', 'projects-wp' ); ?> <?php esc_html_e( 'No URL linked.', 'projects-wp' ); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php elseif ( ! empty( $repo_url ) ) : ?>
|
||||
<a href="<?php echo esc_url( $browse_url ); ?>" class="button project-repo-button" target="_blank" rel="noopener noreferrer">
|
||||
<?php esc_html_e( 'View Repo', 'projects-wp' ); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<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">
|
||||
@@ -83,6 +107,14 @@ if ( $last_updated ) {
|
||||
?>
|
||||
<table class="project-meta-table">
|
||||
<tbody>
|
||||
<?php if ( $is_website ) : ?>
|
||||
<?php if ( $site_platform ) : ?>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Platform:', 'projects-wp' ); ?></th>
|
||||
<td><?php echo nl2br( esc_html( $site_platform ) ); // phpcs:ignore ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php else : ?>
|
||||
<?php if ( $settings['templates']['version'] ) : ?>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Version:', 'projects-wp' ); ?></th>
|
||||
@@ -115,43 +147,45 @@ if ( $last_updated ) {
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
if ( ! empty( $repo_data ) ) :
|
||||
if ( isset( $repo_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) : ?>
|
||||
if ( isset( $repo_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) :
|
||||
?>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Stars:', 'projects-wp' ); ?></th>
|
||||
<td><?php esc_html_e( $repo_data['stargazers_count'] ); ?></td>
|
||||
</tr>
|
||||
<?php endif;
|
||||
if ( isset( $repo_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( $repo_data['forks_count'] ); ?></td>
|
||||
</tr>
|
||||
<?php endif;
|
||||
if ( isset( $repo_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( $repo_data['open_issues_count'] ); ?></td>
|
||||
</tr>
|
||||
<?php endif;
|
||||
<?php
|
||||
endif;
|
||||
endif;
|
||||
endif; // end repo-mode meta rows (else branch of $is_website).
|
||||
?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
|
||||
<a href="<?php echo esc_url( $browse_url ); ?>">
|
||||
<?php if ( ! $is_website && $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
|
||||
<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; ?>
|
||||
@@ -165,7 +199,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();
|
||||
}
|
||||
|
||||
@@ -136,6 +136,29 @@ class Gitea_Provider_Test extends \PHPUnit\Framework\TestCase {
|
||||
);
|
||||
}
|
||||
|
||||
public function test_release_object_shape_picks_first_tag_from_object(): void {
|
||||
// Gitea's /releases/latest returns a single release OBJECT (associative array),
|
||||
// not a list. The implementation must accept either shape.
|
||||
$body = json_encode( [
|
||||
'tag_name' => 'v2.1.0',
|
||||
'name' => 'v2.1.0',
|
||||
'assets' => [
|
||||
[ 'name' => 'release.zip', 'browser_download_url' => 'https://example/release.zip' ],
|
||||
],
|
||||
] );
|
||||
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||
->twice() // get_release_url and get_latest_version both fetch
|
||||
->andReturn( [
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [],
|
||||
'body' => $body,
|
||||
] );
|
||||
|
||||
$provider = $this->make();
|
||||
$this->assertSame( 'https://example/release.zip', $provider->get_release_url() );
|
||||
$this->assertSame( 'v2.1.0', $provider->get_latest_version() );
|
||||
}
|
||||
|
||||
public function test_release_url_empty_array_returns_null(): void {
|
||||
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||
->once()
|
||||
|
||||
@@ -142,4 +142,115 @@ class Metabox_Test extends \PHPUnit\Framework\TestCase {
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
|
||||
$this->assertContains( '_projects_portfolio_github_url', $deleted );
|
||||
}
|
||||
|
||||
public function test_website_mode_persists_website_fields_and_clears_repo_fields(): void {
|
||||
$existing = [
|
||||
'_projects_portfolio_provider' => 'github',
|
||||
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
|
||||
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
|
||||
];
|
||||
$deleted = [];
|
||||
\Brain\Monkey\Functions\stubs( [
|
||||
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
|
||||
return $existing[ $key ] ?? '';
|
||||
},
|
||||
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
|
||||
$existing[ $key ] = $value;
|
||||
return true;
|
||||
},
|
||||
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
|
||||
unset( $existing[ $key ] );
|
||||
$deleted[] = $key;
|
||||
return true;
|
||||
},
|
||||
'wp_verify_nonce' => function () { return true; },
|
||||
'wp_nonce_field' => function () { /* noop */ },
|
||||
] );
|
||||
|
||||
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
|
||||
$_POST['projects_portfolio_is_website'] = '1';
|
||||
$_POST['projects_portfolio_site_url'] = 'https://example.com';
|
||||
$_POST['projects_portfolio_site_platform'] = 'WordPress';
|
||||
$_POST['projects_portfolio_site_screenshot_id'] = '42';
|
||||
|
||||
projects_portfolio_save_meta_box( 11 );
|
||||
|
||||
$this->assertSame( '1', $existing['_projects_portfolio_is_website'] );
|
||||
$this->assertSame( 'https://example.com', $existing['_projects_portfolio_site_url'] );
|
||||
$this->assertSame( 'WordPress', $existing['_projects_portfolio_site_platform'] );
|
||||
$this->assertSame( 42, $existing['_projects_portfolio_site_screenshot_id'] );
|
||||
|
||||
// Repo-mode fields should be cleared when switching to website mode.
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_provider', $existing );
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_repo_url', $existing );
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
|
||||
$this->assertContains( '_projects_portfolio_provider', $deleted );
|
||||
$this->assertContains( '_projects_portfolio_repo_url', $deleted );
|
||||
$this->assertContains( '_projects_portfolio_github_url', $deleted );
|
||||
}
|
||||
|
||||
public function test_unchecked_website_checkbox_clears_website_fields(): void {
|
||||
$existing = [
|
||||
'_projects_portfolio_is_website' => '1',
|
||||
'_projects_portfolio_site_url' => 'https://example.com',
|
||||
'_projects_portfolio_site_platform' => 'WordPress',
|
||||
'_projects_portfolio_site_screenshot_id' => 42,
|
||||
];
|
||||
$deleted = [];
|
||||
\Brain\Monkey\Functions\stubs( [
|
||||
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
|
||||
return $existing[ $key ] ?? '';
|
||||
},
|
||||
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
|
||||
$existing[ $key ] = $value;
|
||||
return true;
|
||||
},
|
||||
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
|
||||
unset( $existing[ $key ] );
|
||||
$deleted[] = $key;
|
||||
return true;
|
||||
},
|
||||
'wp_verify_nonce' => function () { return true; },
|
||||
'wp_nonce_field' => function () { /* noop */ },
|
||||
] );
|
||||
|
||||
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
|
||||
// is_website is absent — repo mode is implied.
|
||||
|
||||
projects_portfolio_save_meta_box( 13 );
|
||||
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_is_website', $existing );
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_site_url', $existing );
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_site_platform', $existing );
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_site_screenshot_id', $existing );
|
||||
$this->assertContains( '_projects_portfolio_is_website', $deleted );
|
||||
}
|
||||
|
||||
public function test_existing_repo_post_without_checkbox_keeps_repo_fields(): void {
|
||||
$existing = [
|
||||
'_projects_portfolio_provider' => 'github',
|
||||
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
|
||||
];
|
||||
\Brain\Monkey\Functions\stubs( [
|
||||
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
|
||||
return $existing[ $key ] ?? '';
|
||||
},
|
||||
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
|
||||
$existing[ $key ] = $value;
|
||||
return true;
|
||||
},
|
||||
'delete_post_meta' => function () { return true; },
|
||||
'wp_verify_nonce' => function () { return true; },
|
||||
'wp_nonce_field' => function () { /* noop */ },
|
||||
] );
|
||||
|
||||
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
|
||||
// is_website not present in $_POST — should be treated as unchecked.
|
||||
|
||||
projects_portfolio_save_meta_box( 17 );
|
||||
|
||||
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
|
||||
$this->assertSame( 'https://github.com/owner/repo', $existing['_projects_portfolio_repo_url'] );
|
||||
$this->assertArrayNotHasKey( '_projects_portfolio_is_website', $existing );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,12 @@ if ( ! function_exists( 'wp_unslash' ) ) {
|
||||
if ( ! function_exists( 'esc_url_raw' ) ) {
|
||||
function esc_url_raw( $url ) { return $url; }
|
||||
}
|
||||
if ( ! function_exists( 'sanitize_text_field' ) ) {
|
||||
function sanitize_text_field( $str ) { return is_string( $str ) ? trim( $str ) : ''; }
|
||||
}
|
||||
if ( ! function_exists( 'absint' ) ) {
|
||||
function absint( $maybeint ) { return abs( (int) $maybeint ); }
|
||||
}
|
||||
|
||||
// Minimal WP_Error stub if Brain\Monkey doesn't supply one.
|
||||
if ( ! class_exists( 'WP_Error' ) ) {
|
||||
|
||||