WP Plugin Starter 1.0.0: a fresh start

The parts every wpallstars plugin needs: settings screen with search and an empty General tab, Read Me tab, features as classes, replaced plugins, the shared GitHub updater, release and check scripts, CI and the shared rules. scripts/rename-plugin.sh starts a new plugin; scripts/sync-core.sh keeps plugins' core files in step with this starter. Built with aidevops (https://aidevops.sh).
This commit is contained in:
marcus committed 2026-10-03 03:30:42 +01:00
commit 2f91c3e6af
62 files changed
+10929

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
{
"root_files": {
"allow": [
"readme.txt",
"changelog.txt",
"STANDARDS.md",
"DEVELOPMENT.md",
"RELEASING.md",
"LAUNCH.md",
"TESTING.md",
"STABILITY.md"
]
}
}
+47
View File
@@ -0,0 +1,47 @@
# Files excluded from the release builds (scripts/build-release.sh, wp dist-archive).
# The WordPress.org build also leaves out the files in .distignore-wporg.
.git
.gitattributes
.gitignore
.distignore
.distignore-wporg
# WordPress.org SVN assets (banners) and their source; admin/images/banner.svg ships instead.
.wordpress-org
.woodpecker.yml
# Development tools and CI (composer.json lists development tools only).
.github
.editorconfig
.gitleaks.toml
.aidevops.json
composer.json
composer.lock
phpcs.xml.dist
phpcs.xml
phpstan.neon.dist
phpstan.neon
phpstan-baseline.neon
phpstan-plugin.neon
/dist
.DS_Store
.aider*
# Local AI tooling state (aidevops), never part of the plugin.
.agents
.security-analysis
*.bak
.metadata_never_index
.syncing
AGENTS.md
CONTRIBUTING.md
DEVELOPMENT.md
LAUNCH.md
SECURITY.md
STANDARDS.md
RELEASING.md
ROADMAP.md
STABILITY.md
TESTING.md
/scripts
reference-plugins
project-documents
node_modules
/vendor
+8
View File
@@ -0,0 +1,8 @@
# Files left out of the WordPress.org build, on top of .distignore
# (rsync exclude patterns; a leading / anchors to the plugin folder).
# Plugins hosted on WordPress.org may not install or update code from
# anywhere else, so updates from GitHub stay in GitHub releases only.
# scripts/build-release.sh also removes the Git Updater header lines
# (GitHub Plugin URI, Primary Branch, Release Asset) from wp-plugin-starter-template.php.
/includes/github-updater/
# Keep README.md: the admin Read Me tab shows it (admin/data/readme.php).
+15
View File
@@ -0,0 +1,15 @@
# Opt out of AI model training
* ai-training=false
# Keep development files out of release archives (git archive).
/.distignore export-ignore
/.distignore-wporg export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.woodpecker.yml export-ignore
/DEVELOPMENT.md export-ignore
/RELEASING.md export-ignore
/ROADMAP.md export-ignore
/STABILITY.md export-ignore
/TESTING.md export-ignore
/scripts export-ignore
+62
View File
@@ -0,0 +1,62 @@
name: Bug report
description: Something in WP Plugin Starter does not work as it should.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Security problems: please do not report them here. See SECURITY.md.
- type: textarea
id: what
attributes:
label: What happened
description: What you saw, and what you expected instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: The feature involved, its settings, and what you clicked.
placeholder: |
1. Turn on …
2. Open …
3. See …
validations:
required: true
- type: input
id: version
attributes:
label: WP Plugin Starter version
description: Shown on the Plugins screen.
validations:
required: true
- type: dropdown
id: source
attributes:
label: Installed from
options:
- GitHub release
- WordPress.org
- Other
validations:
required: true
- type: input
id: environment
attributes:
label: WordPress and PHP versions
description: Tools → Site Health → Info shows both.
placeholder: WordPress 6.8, PHP 8.3
validations:
required: true
- type: textarea
id: log
attributes:
label: Messages in debug.log
description: Lines from wp-content/debug.log that mention wpstarter, if any. Remove anything private first.
render: text
- type: textarea
id: more
attributes:
label: Anything else
description: Theme, other plugins involved, multisite, screenshots.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security problem
url: https://github.com/wpallstars/wp-plugin-starter-template-for-ai-coding/blob/main/SECURITY.md
about: Report security problems privately, never in a public issue.
@@ -0,0 +1,27 @@
name: Feature request
description: Suggest a feature or an improvement.
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: The problem
description: What is slow, missing or annoying today, and for whom.
validations:
required: true
- type: textarea
id: idea
attributes:
label: What WP Plugin Starter could do
description: What it would do on its own, without new settings where possible.
validations:
required: true
- type: input
id: replaces
attributes:
label: Plugin it would replace
description: If another plugin does this now, its name and WordPress.org page.
- type: textarea
id: more
attributes:
label: Anything else
+21
View File
@@ -0,0 +1,21 @@
# Weekly updates for the pinned GitHub Actions and the Composer development
# tools (composer.json; the plugin itself has no Composer dependencies).
# Each ecosystem's updates come grouped in one pull request.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
- package-ecosystem: composer
directory: /
schedule:
interval: weekly
versioning-strategy: lockfile-only
groups:
dev-tools:
patterns: ["*"]
+19
View File
@@ -0,0 +1,19 @@
## What and why
<!-- What changed, and the problem it solves. Link the issue: Resolves #123. -->
## How it was tested
<!-- The steps you took on a real WordPress site, and what you saw. -->
- [ ] `scripts/lint.sh` passes.
- [ ] Tested the changed feature on a WordPress site, with no new messages
mentioning `wpstarter` in `debug.log`.
- [ ] WordPress 6.2 with PHP 7.4, if this uses a core function. <!-- or n/a -->
- [ ] Kadence light and dark mode, if this changes front-end styles. <!-- or n/a -->
## Checklist
- [ ] Changelog lines in `README.md`, `changelog.txt` and `readme.txt`.
- [ ] New options, post meta, transients or cron hooks are removed in `uninstall.php`.
- [ ] New features are off by default.
+138
View File
@@ -0,0 +1,138 @@
# Checks for every pull request and every push to main. Run the same checks
# locally with scripts/lint.sh, scripts/preflight-release.sh,
# scripts/plugin-check.sh and scripts/smoke-test.sh (DEVELOPMENT.md).
#
# Lint runs on every push. The release and smoke-test jobs take longer, so
# draft pull requests skip them until they are marked ready for review.
# Actions are pinned to commit SHAs; Dependabot keeps them up to date.
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint (PHP 7.4)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: "7.4"
coverage: none
tools: composer:v2
- name: Composer cache folder
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install development tools
run: composer install --no-interaction --no-progress --prefer-dist
# PHPStan's result cache: the latest run's is restored, so only files
# changed since then are analysed again.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .cache/phpstan
key: phpstan-${{ github.run_id }}
restore-keys: phpstan-
- name: PHP syntax (7.4), JavaScript syntax, ShellCheck, PHPCS, PHPStan
run: scripts/lint.sh php js shell phpcs phpstan
- name: actionlint
run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color
release:
name: Release build, preflight and Plugin Check
needs: lint
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: "7.4"
coverage: none
tools: none
- name: Preflight
run: scripts/preflight-release.sh --offline --no-docker
- name: Build both zips
run: scripts/build-release.sh --out dist
- name: Plugin Check
run: |
args=()
for zip in dist/*.zip; do
args+=(--zip "$zip")
done
scripts/plugin-check.sh "${args[@]}" --keep-output dist/plugin-check
- name: Keep the zips and reports for testing
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ github.event.repository.name }}-build-${{ github.sha }}
path: dist/
retention-days: 7
if-no-files-found: ignore
smoke:
name: Smoke test (WordPress ${{ matrix.wp }}, PHP ${{ matrix.php }})
needs: lint
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
# Minimums (readme.txt, plugin header).
- wp: "6.2"
php: "7.4"
- wp: latest
php: "8.3"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Smoke test
run: scripts/smoke-test.sh --wp "${{ matrix.wp }}" --php "${{ matrix.php }}" --keep-log debug.log
- name: Keep debug.log
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-log-wp${{ matrix.wp }}-php${{ matrix.php }}
path: debug.log
retention-days: 7
if-no-files-found: ignore
+73
View File
@@ -0,0 +1,73 @@
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
# Development & Local Environment
.aider*
.git_status_output.txt
.git_status_temp.txt
project-documents/
reference-plugins/
*.bak*
node_modules/
package-lock.json
.cache/
vendor/
# WordPress specific
*.log
wp-config.php
wp-content/advanced-cache.php
wp-content/backup-db/
wp-content/backups/
wp-content/blogs.dir/
wp-content/cache/
wp-content/upgrade/
wp-content/uploads/
# IDE & Editors
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
*.swp
*.swo
# Keep .woodpecker.yml if needed for CI/CD
# .woodpecker.yml
# aidevops runtime state
.agents/loop-state/
# Release builds (scripts/build-release.sh)
/dist/
+13
View File
@@ -0,0 +1,13 @@
# gitleaks: secret scan of the Git history (DEVELOPMENT.md → Secrets in history).
# Keep gitleaks' own rules; list only checked false positives here, each with
# its reason and as narrow as possible.
[extend]
useDefault = true
[[allowlists]]
description = "Fluent Forms field keys (el_ and a uniqid()) in starter forms, not secrets"
condition = "AND"
paths = ['''^starters/fluentform\.json$''']
regexTarget = "line"
regexes = ['''"uniqElKey": "el_[0-9.]+"''']
Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+129
View File
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
WP Plugin Starter banner (WordPress.org banners and the Read Me tab).
Source: .wordpress-org/banner.svg. Edit that, then run scripts/build-banner.sh.
Branding follows wpallstars.com: navy, red and cream, Zilla Slab italic
headings, the wpallstars plugin stack in red only, the logo's arc of five stars and its red double stripes (here with cream between).
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1544" height="500" viewBox="0 0 1544 500">
<title>WPALLSTARS WP Plugin Starter: Built with AI</title>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#000c1f"/>
<stop offset="0.55" stop-color="#001537"/>
<stop offset="1" stop-color="#0d264d"/>
</linearGradient>
<radialGradient id="glow-blue" cx="1214" cy="270" r="440" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#bb0000" stop-opacity="0.45"/>
<stop offset="1" stop-color="#bb0000" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow-red" cx="200" cy="580" r="520" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#bb0000" stop-opacity="0.2"/>
<stop offset="1" stop-color="#bb0000" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow-gold" cx="1214" cy="76" r="120" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffc94d" stop-opacity="0.45"/>
<stop offset="1" stop-color="#ffc94d" stop-opacity="0"/>
</radialGradient>
<radialGradient id="dots-fade" cx="1214" cy="260" r="560" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="1"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</radialGradient>
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="14" cy="14" r="1.5" fill="#ffffea" fill-opacity="0.12"/>
</pattern>
<mask id="dots-mask">
<rect width="1544" height="500" fill="url(#dots-fade)"/>
</mask>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffe08a"/>
<stop offset="1" stop-color="#f5a524"/>
</linearGradient>
<linearGradient id="gold-text" x1="96" y1="280" x2="700" y2="340" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffd970"/>
<stop offset="1" stop-color="#f5a524"/>
</linearGradient>
<linearGradient id="top-red" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ef4444"/>
<stop offset="1" stop-color="#bb0000"/>
</linearGradient>
<linearGradient id="top-cream" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff8a8a"/>
<stop offset="1" stop-color="#e03131"/>
</linearGradient>
<linearGradient id="top-blue" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#c81e1e"/>
<stop offset="1" stop-color="#7a0000"/>
</linearGradient>
<!-- A five-pointed star, radius 1, centred on 0,0 -->
<polygon id="star" points="0,-1 0.2245,-0.309 0.9511,-0.309 0.3633,0.118 0.5878,0.809 0,0.382 -0.5878,0.809 -0.3633,0.118 -0.9511,-0.309 -0.2245,-0.309"/>
</defs>
<!-- Background -->
<rect width="1544" height="500" fill="url(#bg)"/>
<rect width="1544" height="500" fill="url(#glow-red)"/>
<rect width="1544" height="500" fill="url(#glow-blue)"/>
<rect width="1544" height="500" fill="url(#dots)" mask="url(#dots-mask)"/>
<!-- wpallstars.com's red double lines, with a cream line between them. -->
<g>
<rect y="0" width="1544" height="6" fill="#bb0000"/>
<rect y="6" width="1544" height="5" fill="#ffffea"/>
<rect y="11" width="1544" height="6" fill="#bb0000"/>
<rect y="483" width="1544" height="6" fill="#bb0000"/>
<rect y="489" width="1544" height="5" fill="#ffffea"/>
<rect y="494" width="1544" height="6" fill="#bb0000"/>
</g>
<!-- The picture: scaled down a little so it keeps clear of the stripes -->
<g transform="translate(1214 248) scale(0.86) translate(-1214 -248)">
<!-- The stack: red, cream and blue layers, each with a top face and two sides -->
<g stroke-linejoin="round">
<g transform="translate(0 350)">
<polygon points="1064,0 1214,87 1214,117 1064,30" fill="#5c0000"/>
<polygon points="1214,87 1364,0 1364,30 1214,117" fill="#420000"/>
<polygon points="1214,-87 1364,0 1214,87 1064,0" fill="url(#top-blue)"/>
<polygon points="1214,-61 1319,0 1214,61 1109,0" fill="none" stroke="#ffffff" stroke-opacity="0.3" stroke-width="2"/>
</g>
<g transform="translate(0 294)">
<polygon points="1064,0 1214,87 1214,117 1064,30" fill="#c22424"/>
<polygon points="1214,87 1364,0 1364,30 1214,117" fill="#9e1414"/>
<polygon points="1214,-87 1364,0 1214,87 1064,0" fill="url(#top-cream)"/>
<polygon points="1214,-61 1319,0 1214,61 1109,0" fill="none" stroke="#001537" stroke-opacity="0.18" stroke-width="2"/>
</g>
<g transform="translate(0 238)">
<polygon points="1064,0 1214,87 1214,117 1064,30" fill="#940000"/>
<polygon points="1214,87 1364,0 1364,30 1214,117" fill="#700000"/>
<polygon points="1214,-87 1364,0 1214,87 1064,0" fill="url(#top-red)"/>
<!-- The WordPress Plugins icon (Dashicons admin-plugins, GPLv2 or later),
laid flat on the top face: icon x runs along the top-right edge and
icon y along the top-left edge, each 20-unit side 66% of the face.
Turned 45 degrees so the plug points along the back-left edge. -->
<g transform="matrix(4.95 2.871 -4.95 2.871 1214 -57.42)">
<path fill="#5c0000" fill-opacity="0.55" transform="translate(0.3 0.3) rotate(-45 10 10)" d="M13.11 4.36L9.87 7.6 8 5.73l3.24-3.24c.35-.34 1.05-.2 1.56.32.52.51.66 1.21.31 1.55zm-8 1.77l.91-1.12 9.01 9.01-1.19.84c-.71.71-2.63 1.16-3.82 1.16H6.14L4.9 17.26c-.59.59-1.54.59-2.12 0-.59-.58-.59-1.53 0-2.12l1.24-1.24v-3.88c0-1.13.4-3.19 1.09-3.89zm7.26 3.97l3.24-3.24c.34-.35 1.04-.21 1.55.31.52.51.66 1.21.31 1.55l-3.24 3.25z"/>
<path fill="#ffffea" transform="rotate(-45 10 10)" d="M13.11 4.36L9.87 7.6 8 5.73l3.24-3.24c.35-.34 1.05-.2 1.56.32.52.51.66 1.21.31 1.55zm-8 1.77l.91-1.12 9.01 9.01-1.19.84c-.71.71-2.63 1.16-3.82 1.16H6.14L4.9 17.26c-.59.59-1.54.59-2.12 0-.59-.58-.59-1.53 0-2.12l1.24-1.24v-3.88c0-1.13.4-3.19 1.09-3.89zm7.26 3.97l3.24-3.24c.34-.35 1.04-.21 1.55.31.52.51.66 1.21.31 1.55l-3.24 3.25z"/>
</g>
</g>
</g>
<!-- The logo's arc of five stars: gold in the middle, floating above the stack -->
<circle cx="1214" cy="76" r="120" fill="url(#glow-gold)"/>
<g stroke-linejoin="round">
<g fill="#bb0000" stroke="#ffffea" stroke-width="5">
<use xlink:href="#star" transform="translate(1043 150) scale(17)" stroke-width="0.3"/>
<use xlink:href="#star" transform="translate(1110 100) scale(26)" stroke-width="0.2"/>
<use xlink:href="#star" transform="translate(1318 100) scale(26)" stroke-width="0.2"/>
<use xlink:href="#star" transform="translate(1385 150) scale(17)" stroke-width="0.3"/>
</g>
<use xlink:href="#star" transform="translate(1214 78) scale(54)" fill="url(#gold)" stroke="#ffffea" stroke-width="0.11"/>
</g>
</g>
<!-- Words -->
<g font-family="Zilla Slab" text-rendering="geometricPrecision">
<text x="98" y="128" font-size="27" font-weight="600" letter-spacing="5" fill="#ff999b">WPALLSTARS</text>
<text x="92" y="240" font-size="104" font-weight="700" font-style="italic" letter-spacing="-1" fill="#ffffea">WP Plugin Starter</text>
<text x="96" y="326" font-size="72" font-weight="700" font-style="italic" fill="url(#gold-text)">Built with AI</text>
<text x="98" y="396" font-size="33" font-weight="500" fill="#c2cad6">A clean start for WordPress plugins, made with aidevops.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

+51
View File
@@ -0,0 +1,51 @@
# WP Plugin Starter — agent guide
The wpallstars starter plugin: what every wpallstars plugin is made from.
It has no features of its own, only the parts every plugin needs.
**Read `STANDARDS.md` before any change.** It holds the rules every plugin
made from the starter shares: structure and core files, code rules, Updates
from GitHub, releases, front-end styling and dark mode, and testing. This
repository holds the master copy of it and of every core file
(`scripts/core-files.txt`). This file holds only what is the starter's own.
| Placeholder in `STANDARDS.md` | WP Plugin Starter |
|---|---|
| `{slug}` | `wp-plugin-starter-template` (main file `wp-plugin-starter-template.php`) |
| `{prefix}` | `wpstarter` |
| `{Prefix}` | `WPStarter` |
| `{PREFIX}` | `WPSTARTER` |
| `{Name}` | WP Plugin Starter |
| `{css}` | `wps` |
User docs: `README.md` (developers, and the Read Me tab) and `readme.txt`.
Development: `DEVELOPMENT.md`. Releases: `RELEASING.md`; launch state:
`LAUNCH.md`.
## What belongs here
- Only what every plugin needs. A part one plugin needs stays in that
plugin (its `{Prefix}_Setup`, a feature or its own files). When a lesson
from a plugin applies to all of them, change the core file here first,
then run `scripts/sync-core.sh` in each plugin (`--check` lists what
differs). Keep core files free of any one plugin's names, examples and
paths: this repository is public.
- `includes/class-wpstarter-setup.php` stays empty of features, so the
settings screen shows the empty General tab and the Read Me tab. Test a
core change in a plugin that uses it, then here.
- `scripts/rename-plugin.sh` must keep working on a fresh copy: after a
change to names in core files, try it on a throwaway clone (rename, lint,
smoke test).
- The banner source is `.wordpress-org/banner.svg`: the wpallstars plugin
stack picture in red only, on wpallstars branding. Rebuild it with
`scripts/build-banner.sh`.
- This repository is public. Never name private repositories, their
issues, or local paths in it (commits, docs, comments or examples).
- Every plugin made from the starter keeps the **Built with AI** credit to
aidevops (https://aidevops.sh) in `README.md` and `readme.txt`.
## Test sites
Install the GitHub build on a local test site with
`scripts/preview-site.sh` (see `DEVELOPMENT.md`), or install the GitHub
zip from `scripts/build-release.sh` on any test site.
+36
View File
@@ -0,0 +1,36 @@
# Contributing to WP Plugin Starter
Thank you for helping. Bug reports, fixes and ideas are all welcome.
## Reporting a problem
Open an issue with the **Bug report** form. Include the versions, the steps
and any messages from `wp-content/debug.log`. Security problems go through
`SECURITY.md`, never a public issue.
## Suggesting a feature
Open an issue with the **Feature request** form before writing code. Each
setting makes a choice for the plugin's users, so a new one has to earn its
place: say what the feature does on its own and what problem it solves.
## Pull requests
1. Read `STANDARDS.md` (how features, settings, migrations and front-end
styles work, the code rules and testing), `AGENTS.md` (what is this
plugin's own) and `DEVELOPMENT.md` (set-up and checks).
2. Keep each pull request to one change. Features are off by default.
3. Run `scripts/lint.sh` and fix what it finds in the code.
4. Test on a real WordPress site, including WordPress 6.2 with PHP 7.4 if
you use a core function, and Kadence light and dark mode if you change
front-end styles.
5. Add a line to the changelogs: `README.md` (details), `changelog.txt`
(for users) and `readme.txt` (one short line).
CI runs the checks on every pull request. Write the pull request description
so a reviewer knows what changed, why, and how you tested it.
## Licence
WP Plugin Starter is GPL-2.0-or-later. By contributing, you agree your work is
released under the same licence.
+206
View File
@@ -0,0 +1,206 @@
# Development
How changes are made and checked in every plugin made from the wpallstars
starter plugin. This file is the same in each of them (names as
placeholders: `STANDARDS.md` lists them, and the plugin's `AGENTS.md` gives
its values). Rules for features, code, styling and testing: `STANDARDS.md`;
the plugin's own rules: `AGENTS.md`. Releases: `RELEASING.md`.
## Workflow
1. Open or pick an issue that says what should change and how to check it.
2. Work on a branch in its own worktree, never on `main`. Branch names:
`feature/…`, `bugfix/…`, `chore/…`.
3. Commit small, working steps. Run `scripts/lint.sh` before pushing.
4. Push and open a pull request (`Resolves #N`). Open it as a draft while
the work is in progress: CI lints every push, and the longer release and
smoke-test jobs start when the pull request is marked ready for review.
5. Check the change on the shared preview site (`scripts/preview-site.sh`,
`STANDARDS.md` → Testing), in light and dark mode for front-end styles.
6. Merge once CI passes. Releases are separate: `RELEASING.md`.
## Set up
Needs PHP 7.4 or later, Composer 2, Node.js (syntax checks only),
ShellCheck and Docker (release checks and smoke test). actionlint is
optional locally; CI runs it.
```bash
composer install
```
`composer.json` lists development tools only. The plugin has no Composer
dependencies, and `vendor/`, `composer.*`, the tool configuration and
`.github/` are left out of release zips (`.distignore`; the preflight fails
if one gets in).
## Checks
Every pull request and every push to `main` runs these in GitHub Actions
(`.github/workflows/ci.yml`). Each one runs the same way locally.
| Check | Command | What it finds |
| --- | --- | --- |
| PHP syntax | `scripts/lint.sh php` | Syntax errors; CI uses PHP 7.4, the minimum. |
| JavaScript syntax | `scripts/lint.sh js` | Syntax errors (`node --check`). |
| Shell scripts | `scripts/lint.sh shell` | ShellCheck findings in `scripts/`. |
| Workflows | `scripts/lint.sh workflows` | actionlint findings in `.github/workflows/`. |
| Coding standards | `scripts/lint.sh phpcs` | WordPress Coding Standards: escaping, sanitising, nonces, prepared SQL, i18n, PHP 7.4 and WordPress 6.2 compatibility (`phpcs.xml.dist`). |
| Static analysis | `scripts/lint.sh phpstan` | Unknown functions, classes and methods, wrong argument counts and types, dead code (PHPStan level 5, `phpstan.neon.dist`). |
| Release build | `scripts/preflight-release.sh --offline` | Versions, headers, `readme.txt`, presets (where the plugin has them) and the contents of both zips. |
| Plugin Check | `scripts/plugin-check.sh` | The WordPress.org review tool, on both zips. |
| Smoke test | `scripts/smoke-test.sh --wp 6.2 --php 7.4` and `scripts/smoke-test.sh` | Installs the GitHub zip, loads the site and admin screens with default settings and with every feature on, runs cron, uninstalls. Fails on any PHP message, a failed page or leftover options. |
`scripts/lint.sh` with no arguments runs the first six.
The scripts work out which plugin they are in from its main file
(`scripts/lib/plugin.sh`): the PHP file at the top of the repository with a
`Plugin Name:` header gives the slug (its file name), the name, the class
prefix (`@package`) and the constant prefix (`define('<PREFIX>_VERSION', …)`).
Environment overrides use that prefix, for example
`{PREFIX}_PREVIEW_SITE` or `{PREFIX}_DB_IMAGE`. Checks for parts only some
plugins have (presets and starter data, the replaced plugins count) run
when their files exist, so the same scripts serve every plugin made from the
starter.
### Coding standards
`phpcs.xml.dist` uses the WordPress ruleset. The plugin's own style
differs from it in a few places (spacing, `array()`, file names, Yoda
conditions), and those sniffs are off; the comments in the file say why.
Security, database and compatibility sniffs stay on. Fix findings in the
code. Where a finding is intended, add an inline
`// phpcs:ignore Sniff.Name -- reason` on that line only.
`vendor/bin/phpcbf` fixes what it can automatically.
### Static analysis
PHPStan reads the code with WordPress's stubs and PHP 7.4's functions.
`scripts/phpstan-bootstrap.php` defines the constants WordPress and the
plugin set while loading. Classes and functions of other plugins the code
uses (WP-CLI, WooCommerce and the like) are ignored in `phpstan-plugin.neon`,
because the code uses them only after checking they are loaded.
`phpstan-baseline.neon` is empty: every finding fails the check. Fix the
code. Where PHPStan or the stubs are wrong (a custom `wp_hash()` scheme, a
check for a method newer WordPress versions have, variables a closure
changes by reference), add an entry under `ignoreErrors` with the
identifier, the file and the reason: in `phpstan.neon.dist` for the files
every plugin made from the starter shares, in `phpstan-plugin.neon` for the
plugin's own files and extra paths. Never put findings in the baseline to
get a change through.
### Secrets in history
Scan the whole Git history before the repository goes public, and after
importing code from elsewhere:
```bash
docker run --rm -v "$PWD:/repo:ro" ghcr.io/gitleaks/gitleaks:latest git /repo --redact --no-banner
```
In a linked worktree, also mount the main repository's `.git` folder at
the same path. `.gitleaks.toml` lists the false positives with reasons. A
real secret is rotated first, then removed from history.
### Smoke test
`scripts/smoke-test.sh` starts a throwaway WordPress in Docker (MariaDB,
Apache and WP-CLI images for the chosen PHP version), so nothing touches
the shared preview site. CI runs it on WordPress 6.2 with PHP 7.4 and on
the latest WordPress with PHP 8.3. Every feature with an on/off setting is
switched on at once, except one with the key `maintenance` (a maintenance
mode would answer every visitor page with its notice). `--keep-log FILE` saves `debug.log`; CI keeps it as an artifact
when the test fails.
## Test site resources
Test sites run the plugin alongside many others (a plugin that recommends
plugins is tested with all of them active). PHP's defaults are too small for that: OPcache fills
and restarts, admin requests queue behind two workers, and the slowdowns
look like bugs. Size every test site, shared or throwaway, with room to
spare:
| Setting | Where (LocalWP, nginx) | Value | PHP or Local default |
|---|---|---|---|
| `opcache.memory_consumption` | `conf/php/php.ini.hbs` | `1024` | 128 |
| `opcache.interned_strings_buffer` | `conf/php/php.ini.hbs` | `64` | 8 |
| `opcache.max_accelerated_files` | `conf/php/php.ini.hbs` | `50000` | 10000 |
| `memory_limit` | `conf/php/php.ini.hbs` | `768M` | 256M |
| `pm.max_children` | `conf/php/php-fpm.d/www.conf.hbs` | `5` | 2 |
Measured on a shared test site with 72 plugins installed: OPcache used
509 MB of 512 MB and restarted, then 315 MB of 1 GB with 31,723 PHP files
cached; pages peaked at 352 MB of memory; the busiest hour needed 5 workers.
- LocalWP keeps these per site in `<site>/conf/`. Edit the `.hbs`
templates, not the generated files under Local's `run/` folder, then
stop and start the site in Local: it rebuilds the configuration only on
start. Add a comment with the date and reason next to each change.
- The OPcache lines sit inside `{{#unless apache}}`: on an Apache site,
move them out or set them in Apache's own configuration.
- Check after the restart: Site Health → Info → Server shows the new values.
- Raise them again when a site needs more, and update this table in the
starter.
- To test low-resource warnings, lower a value on a throwaway site, never
the shared one.
- Docker sites (`scripts/smoke-test.sh`, step 4 in `STANDARDS.md` →
Testing) take the same values in a `.ini` file mounted into
`/usr/local/etc/php/conf.d/`.
## Dependencies
Dependabot (`.github/dependabot.yml`) opens one pull request a week for
the GitHub Actions and one for the Composer tools. Actions are pinned to
commit SHAs with the version in a comment; keep it that way when editing
workflows. Do not add third-party actions that only save a few lines of
shell.
## While private: checks are advisory
The repository is private until it is released, so Actions minutes cost
money and most code-review services are paid. Until then, work moves fast:
- No branch protection or required checks on `main`. A failing check does
not block a merge, so a merge never waits on an unrelated failure.
- Fix any failure your change causes before merging. For a failure your
change did not cause, open an issue with the run link and the first error,
and merge anyway.
- Draft pull requests run only the lint job. A new push cancels the run for
the previous one.
- The build zips and Plugin Check reports are kept for seven days on each
run (artifact `<repository>-build-…`) for testing a branch on a site.
- Review apps that are already installed (CodeRabbit, qlty, Socket) give
advice only. A rate-limited or missing review never holds up a merge.
## At public launch: full sweep
Making the repository public needs the owner's say. Do this sweep in the
same step, while the code-review services are free for
public repositories. It goes through the whole codebase once, then keeps
it at that standard:
1. Turn on the free reviewers for the whole codebase, not just new
changes: CodeRabbit full review, Codacy, SonarCloud (SonarQube Cloud)
and qlty, plus GitHub's CodeQL (PHP and JavaScript), Dependabot
security alerts, secret scanning with push protection, and OpenSSF
Scorecard. Socket keeps checking dependencies.
2. Fix what they find in the code, in small pull requests by area
(security first). Each finding is either fixed, explained in an inline
comment, or marked as a false positive in that service with the reason.
3. Raise the PHPStan level one step at a time (6, then higher if the
findings are real bugs and not noise). The baseline is already empty.
4. Require the CI checks on `main` (Lint, Release build, both Smoke
tests) with a branch ruleset, without "branch must be up to date": the
checks are fast, and changelog lines conflict on every merge.
5. Turn on private vulnerability reporting (Settings → Security), which
`SECURITY.md` asks reporters to use, and add the CI badge to
`README.md`. `SECURITY.md`, `CONTRIBUTING.md` and the issue and pull
request templates are already in place.
6. Run `workflows/public-launch-checklist.md` from the AI DevOps framework
for anything public: no private paths, site names or secrets in the code,
history, issues or docs. Run the history scan above again for commits
made since the last one.
What a plugin has already done ahead of launch is in its `LAUNCH.md`.
+9
View File
@@ -0,0 +1,9 @@
# WP Plugin Starter — launch state
The starter is released on GitHub only. It is not meant for WordPress.org:
it has no features, and plugins made from it are submitted under their own
names. `RELEASING.md` → WordPress.org applies to those plugins, each with its
own `LAUNCH.md`.
`scripts/preflight-release.sh` and `scripts/plugin-check.sh` still run on
every release, so the core files stay ready for a plugin's submission.
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+108
View File
@@ -0,0 +1,108 @@
![WPALLSTARS WP Plugin Starter: Built with AI](admin/images/banner.svg)
# WP Plugin Starter
A clean start for a WordPress plugin: a settings screen, a Read Me tab, updates from GitHub and release scripts, ready for your features.
WP Plugin Starter is what wpallstars plugins are made from. It has no features of its own: it holds the parts every plugin needs, built and tested in real plugins, so a new plugin starts with them working and spends its time on what makes it different.
If this saves you time, headaches and costs, feel free to [buy me a coffee](https://buymeacoffee.com/marcusquinn), or whatever you like, to invest in making more things open-source.
Version: {WPSTARTER_VERSION}
## What you get
- **A settings screen** (Settings → WP Plugin Starter) that features fill by declaring their settings: switches, numbers, text, lists, choices and Media Library pictures, saved instantly with no Save button, searchable, in tabs. With no features yet it shows one empty tab.
- **A Read Me tab** that shows this file, banner included, so users read the same guide inside WordPress as on GitHub.
- **Features as classes**: one file per feature, off by default, with settings, hooks, one-off imports from the plugins it replaces and clean uninstall.
- **Replaced plugins**: a feature that does another plugin's job imports its settings once, waits while that plugin is active, and the Plugins screen suggests deactivating and deleting it.
- **Updates from GitHub**: the shared wpallstars updater (`includes/github-updater/`). Sites get each GitHub release as a normal WordPress update. Every wpallstars plugin carries a copy and only the newest copy on a site runs, so they are all checked together, once.
- **Two builds of each version**: the GitHub release, and a WordPress.org build without the updater, as WordPress.org requires.
- **Scripts and CI**: lint (PHP 7.4, WordPress coding and security rules, PHPStan), a smoke test on a real WordPress, the release build, a preflight check of both zips, Plugin Check, a preview site, the banner build, and `scripts/sync-core.sh` to keep each plugin's shared parts the same as the starter's.
- **Shared rules for people and AI**: `STANDARDS.md` (structure, code rules, releases, styling, testing), `DEVELOPMENT.md` (set-up and checks) and `RELEASING.md`, the same in every plugin made from the starter.
## Start a plugin
1. On GitHub, choose **Use this template** to make your repository, and clone it.
2. Give it its names: `scripts/rename-plugin.sh --slug my-plugin --name "My Plugin" --prefix MyPlugin`. Add `--css mp` for a short CSS prefix and `--repo owner/repo` if it is not under wpallstars. Review with `git diff`, then commit.
3. Replace this README, `readme.txt`, `changelog.txt` and `AGENTS.md` with your plugin's own, and its banner (`.wordpress-org/banner.svg`, then `scripts/build-banner.sh`).
4. Add features: a class in `includes/features/` listed in `MyPlugin_Setup::FEATURES` (see Developers below and `STANDARDS.md`).
5. Keep the shared parts up to date: change them in the starter first, then run `scripts/sync-core.sh` in each plugin (`--check` lists what differs).
The easiest way to do all of this is with [aidevops](https://aidevops.sh): open the repository with it and describe the plugin you want. It reads `AGENTS.md` and `STANDARDS.md`, builds the features, tests them on a real site and runs the release checks.
## Where to find it
Go to **Settings → WP Plugin Starter**. The screen has two groups of tabs:
- **Settings**: General, empty until features add settings. Changes save instantly; there is no Save button. **Search features** (next to the plugin name) finds settings on every tab.
- **About**: this Read Me.
**Report a problem**, at the top right of the screen, opens the plugin’s [GitHub issues](https://github.com/wpallstars/wp-plugin-starter-template-for-ai-coding/issues) in a new tab. Say what you did, what you expected and what happened, with the versions of WordPress, PHP and WP Plugin Starter. Leave out passwords, licence keys and personal data, since issues are public. **Buy me a coffee**, next to it, opens the maker’s [Buy Me a Coffee](https://buymeacoffee.com/marcusquinn) page in a new tab.
## Requirements
- WordPress 6.2 or later
- PHP 7.4 or later
## Updates and releases
There are two builds of each version:
- **GitHub release** (`wp-plugin-starter-template-X.Y.Z.zip` on the repository’s Releases page): everything, including the shared updater in `includes/github-updater/`, so sites get each release as a normal update.
- **WordPress.org**: the same files without the updater (listed in `.distignore-wporg`) and without the `GitHub Plugin URI`, `Primary Branch` and `Release Asset` header lines, because plugins hosted there may not install or update code from elsewhere.
The updater waits while Git Updater is active, so the two never both update a plugin.
Releasing on GitHub:
1. Merge the version change (`Version:` and `WPSTARTER_VERSION` in `wp-plugin-starter-template.php`, `Stable tag:` in `readme.txt`) to `main`.
2. Straight away, tag that commit `vX.Y.Z` and publish a GitHub release with `wp-plugin-starter-template-X.Y.Z.zip` attached. `scripts/build-release.sh --ref vX.Y.Z` builds it (and the WordPress.org zip) from the tag with `.distignore` applied, everything inside a `wp-plugin-starter-template/` folder; `scripts/preflight-release.sh` and `scripts/plugin-check.sh` check them first. Sites pick the latest release whose tag is a plain version number and the asset whose name starts with `wp-plugin-starter-template`, so never attach the WordPress.org zip. Full steps: `RELEASING.md`.
3. Sites offer the update when they next check (within 12 hours, or at once with **Check again** on the Updates screen).
Mark test builds as pre-releases on GitHub (or tag them with letters, such as `v1.2.0-rc1`): sites never offer those. Do not add an `Update URI` header: WordPress.org rejects it, and Plugin Check reports it as an updater.
## Developers
A feature is a class in `includes/features/class-wpstarter-{name}.php` that extends `WPStarter_Feature`, listed in `WPStarter_Setup::FEATURES`. It declares its settings in `settings()`, adds its hooks in `boot()` (returning early unless `self::enabled()`), and can import another plugin’s settings once in `migrate()` with `self::import_setting()`. Anything only this plugin needs goes in `WPStarter_Setup` (`includes/class-wpstarter-setup.php`): features, settings tabs, header links, settings version and its own helpers. The other files in `includes/` and `admin/` are the starter’s core files: `STANDARDS.md` → Structure.
Filters:
- `wpstarter_features`: register a feature class that extends `WPStarter_Feature`.
- `wpstarter_settings_schema`: add or change settings. Each entry sets `type` (bool, int, text, url, lines, domains, select, multi, times or media, a picture from the Media Library stored as its attachment ID), `default`, `label`, `description` and either `tab` or `parent`; select and multi also take `options` (an array or a callable), and multi takes `open` to keep saved values that are not currently registered. A child setting can take `hidden` (true): it is not shown or searched, for wiring that code sets. `reload` (true) makes the saved message ask to reload the page, for changes that show only after a page load. `replaces` (slug => name) shows which plugin a feature replaces. Settings render and save automatically.
- `wpstarter_admin_tabs`: add or reorder admin tabs. Each tab sets `label`, `group` (settings, discover or about), a `render` callback and an optional `capability`; tabs the current user lacks the capability for are hidden.
- `wpstarter_admin_script_deps` and `wpstarter_admin_script_data`: the settings screen script’s dependencies and the data it reads as `wpstarterAdmin` (both with the active tab).
- `wpstarter_can_change_settings`: return false to stop the current user changing WP Plugin Starter’s settings (on top of `manage_options`).
- `wpstarter_replaced_plugin_extras`: what a replaced plugin does on this site that WP Plugin Starter does not (plain names, plugin folder). The Plugins screen names them instead of saying the plugin can go.
- `wpstarter_stored_active_plugins` and `wpstarter_plugins_skipped`: for code that skips plugins on some requests, the active plugin files as stored and whether some are skipped on this request, so features still count those plugins as active.
- `wpallstars_github_plugins` (GitHub builds, shared updater): change which plugins update from GitHub releases (plugin file => `repo` as owner/repo, `asset_only`, `version`, `name`).
- `wpallstars_github_token` (GitHub builds, shared updater): GitHub token for a repository (token, owner/repo), for private repositories; defaults to the `WPALLSTARS_GITHUB_TOKEN` constant.
- `wpallstars_github_updater_enabled` and `wpallstars_github_updater_early` (GitHub builds, shared updater): whether it runs (false while Git Updater is active) and whether plugins also on WordPress.org take GitHub releases first.
Actions:
- `wpstarter_admin_enqueue`: enqueue the plugin’s own admin styles and scripts on its settings screen (active tab), depending on `wpstarter-admin`. Its script can use `wpstarterAdmin.api` (`post`, `speak`, `errorMessage`).
- `wpstarter_setting_saved`: a setting was saved from the admin screen.
- `wpstarter_setting_panel`: print status at the top of a setting’s options panel (setting key, schema entry); wrap it in `<div class="wps-panel-note">`.
- `wpstarter_settings_tab_after`: print a section after a settings tab’s cards (tab slug).
Read a setting with `WPStarter_Settings::get( 'key' )`.
## Uninstall
Deleting the plugin removes its settings, its cached data, who hid lines of the Plugins screen notice about replaced plugins, and the cached GitHub releases.
## Changelog
### 1.0.0
- A fresh start, made from the parts every wpallstars plugin needs, taken from plugins in daily use: the settings screen with search, the Read Me tab, features as classes, replaced plugins, the shared GitHub updater, the release and check scripts, CI and the shared rules (`STANDARDS.md`, `DEVELOPMENT.md`, `RELEASING.md`). Earlier versions of this starter are retired; start again from this one.
- New: `scripts/rename-plugin.sh` gives a copy of the starter its own names, and `scripts/sync-core.sh` keeps a plugin's core files the same as the starter's (`--check` lists any that differ).
## Built with AI
WP Plugin Starter is built and maintained with [aidevops](https://aidevops.sh), the same developer's open-source AI harness for creating and managing anything online with AI, plugins like this one included. It is free on [GitHub](https://github.com/marcusquinn/aidevops).
## License
GPL-2.0-or-later.
+130
View File
@@ -0,0 +1,130 @@
# Releasing
How every plugin made from the wpallstars starter plugin is released. This
file is the same in each of them (names as placeholders: `STANDARDS.md`
lists them, and the plugin's `AGENTS.md` gives its values). The plugin's own
submission state and guideline review: `LAUNCH.md`.
Releases on GitHub and the WordPress.org submission need the owner's say.
None of the scripts below tags, publishes, uploads or commits anything.
## Scripts
| Script | What it does |
|--------|--------------|
| `scripts/build-release.sh [--ref REF] [--out DIR]` | Builds both zips from a Git ref (default `HEAD`) into `dist/` (gitignored), with `SHA256SUMS`. Files come from Git, never the working tree. |
| `scripts/preflight-release.sh [--ref REF] [--strict] [--offline]` | Checks versions, headers, readme, both zips (layout, development files, PHP 7.4 and JS syntax, updater code), remote assets, presets and starter data where the plugin has them, and the Git tag. Errors stop a release; `--strict` also fails on warnings, for a WordPress.org submission. |
| `scripts/plugin-check.sh [--ref REF] [--zip FILE]` | Runs Plugin Check on both zips in a disposable WordPress in Docker, then removes it. |
The two builds of each version:
| Zip | Contents | Goes to |
|-----|----------|---------|
| `{slug}-X.Y.Z.zip` | Files in Git, less `.distignore` | GitHub release asset |
| `wordpress-org-{slug}-X.Y.Z.zip` | The same, less `.distignore-wporg` (the GitHub updater) and the `GitHub Plugin URI`, `Primary Branch` and `Release Asset` header lines | WordPress.org only |
Sites install the release asset whose name starts with `{slug}` (the shared
GitHub updater, and Git Updater where it is still active), so the
WordPress.org zip is named differently and must never be attached to a
GitHub release.
Plugin Check reports the GitHub updater as an updater
(`plugin_updater_detected`, `update_modification_detected`, and
`OffloadedContent` for its raw.githubusercontent.com address) and its shared
`wpallstars_` names as unprefixed, in the GitHub zip; `scripts/plugin-check.sh`
lists those as expected there and fails on the updater findings in the
WordPress.org zip.
## GitHub release
1. In a pull request, set the version in `{slug}.php` (`Version:` and
`{PREFIX}_VERSION`) and `readme.txt` (`Stable tag:`), rename the
changelog's Unreleased section to the version in `readme.txt`,
`changelog.txt` and `README.md`, and add an upgrade notice if people need
to act. `readme.txt` keeps only the newest version, in short, and must stay
under 10 KB; `changelog.txt` keeps every version in full.
Numbers only (`1.2.3`): sites still on Git Updater offer a `Version:` on
`main` to every site, and the shared updater skips tags with letters.
2. On the pull request's branch: `scripts/preflight-release.sh` (no errors) and
`scripts/plugin-check.sh` (no errors).
3. Merge, then straight away:
```bash
git fetch origin
scripts/preflight-release.sh --ref origin/main # no errors before tagging
git tag -a vX.Y.Z origin/main -m "{Name} X.Y.Z"
scripts/build-release.sh --ref vX.Y.Z
git push origin vX.Y.Z
gh release create vX.Y.Z dist/{slug}-X.Y.Z.zip --title "{Name} X.Y.Z" --notes-file <notes>
```
Sites with the shared updater see the release when they next check.
Sites still on Git Updater are offered the `Version:` on `main` before the
release exists, so do this in the same sitting as the merge.
4. Check the release has exactly one asset, `{slug}-X.Y.Z.zip`, and on a
site with the previous version that **Check again** on the Updates screen
shows the update and that it installs.
Sites read the repository without signing in, so it must be public for sites
to get updates. While it is private, a test site can use a read-only token
in `WPALLSTARS_GITHUB_TOKEN` (`wp-config.php`; it serves every plugin with
the shared updater). Making it public needs the owner's say; do the quality
sweep in `DEVELOPMENT.md` → At public launch in the same step.
## WordPress.org
Guidelines: [Detailed Plugin Guidelines](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/),
[Planning, submitting and maintaining](https://developer.wordpress.org/plugins/wordpress-org/planning-submitting-and-maintaining-plugins/),
[Plugin readmes](https://developer.wordpress.org/plugins/wordpress-org/how-your-readme-txt-works/),
[Plugin assets](https://developer.wordpress.org/plugins/wordpress-org/plugin-assets/),
[Using Subversion](https://developer.wordpress.org/plugins/wordpress-org/how-to-use-subversion/).
Before submitting, work through the plugin's `LAUNCH.md`: what
`scripts/preflight-release.sh --strict` still reports, and a review of the
code against each guideline (no code from elsewhere, external services,
scripts and styles shipped with the plugin, links, admin notices, defaults,
files outside the plugin folder, uninstall, GPL, name and trademarks, a
complete plugin).
### Submitting
1. `scripts/build-release.sh --ref vX.Y.Z` (a version already released on
GitHub), then `scripts/preflight-release.sh --ref vX.Y.Z --strict` passes,
or every warning is accepted.
2. `scripts/plugin-check.sh --ref vX.Y.Z` reports no errors; read every
warning.
3. Test the WordPress.org zip on a clean site (latest WordPress and 6.2 with
PHP 7.4), with `WP_DEBUG` on: activate, turn each feature on and off,
deactivate, delete. `debug.log` stays empty.
4. Signed in as the owner's WordPress.org account, upload
`dist/wordpress-org-{slug}-X.Y.Z.zip` at
[Add your plugin](https://wordpress.org/plugins/developers/add/), and ask
for the `{slug}` slug in the notes.
5. Review takes about 1 to 10 business days. Reply to the reviewer's email
from the same account; fix issues in the repository, not in a copy.
### After approval (Subversion)
The SVN repository is `https://plugins.svn.wordpress.org/{slug}/` (or the
slug given). It is for releases only, not development. The SVN password is
separate from the account password and is set on the WordPress.org profile
(see Using Subversion). Install Subversion first (`brew install subversion`).
1. Check out the empty repository: `svn co https://plugins.svn.wordpress.org/{slug}/ svn-{slug}`.
2. Unzip the WordPress.org build's `{slug}/` folder into `trunk/` (no zip
files in SVN), `svn add` new files, `svn rm` removed ones.
3. Copy trunk to the tag: `svn cp trunk tags/X.Y.Z`. `Stable tag:` in
`trunk/readme.txt` and `tags/X.Y.Z/readme.txt` must be `X.Y.Z`; never
`trunk`.
4. Put the banners, icons and screenshots in `assets/`.
5. `svn ci -m "Release X.Y.Z"`, then check the plugin page and download.
6. Consider release confirmation emails (Plugin Handbook → Release
Confirmation Emails), so a release goes out only after it is confirmed.
Each later WordPress.org release: build from the tag that is already on GitHub,
`--strict` preflight, Plugin Check, then steps 2 to 5. Readme-only changes
(such as raising Tested up to) go to trunk and the current tag.
Once listed, sites with the GitHub build update from WordPress.org, unless
the plugin returns true from `wpallstars_github_updater_early` (a setting
such as Early updates from GitHub), which keeps them on GitHub releases.
+37
View File
@@ -0,0 +1,37 @@
# Security policy
## Supported versions
Security fixes go into the latest release only. Update to it before
reporting, and check that the problem is still there.
## Reporting a vulnerability
Please do not open a public issue, pull request or discussion for a
security problem.
Report it privately on GitHub: open the repository's **Security** tab and
choose **Report a vulnerability**. Include:
- the WP Plugin Starter version, and whether it came from GitHub or
WordPress.org;
- the WordPress and PHP versions;
- the feature involved and the settings needed;
- steps to reproduce it, and what an attacker gains (for example, which
role they need and what they can then read or change).
We reply within 30 days, agree a fix and a date with you, and credit you in
the changelog unless you would rather not be named. If it is urgent (for
example, it is already being used against sites), report it on GitHub and
also email security@evergreen.je with a link to the report. Follow-up
emails are welcome.
## Scope
In scope: the code in this repository, as installed from a GitHub release
or WordPress.org.
Out of scope: other plugins, themes and services that WP Plugin Starter
recommends, links to or works with (report those to their makers), and
problems that need an administrator account, since administrators can
already run code on their site.
+278
View File
@@ -0,0 +1,278 @@
# Plugin standards
Rules every plugin made from the wpallstars starter plugin follows. This file
is the same in each of them: the starter holds the master copy, so a lesson
learned in one plugin goes into the starter's copy and then to every plugin
(`scripts/sync-core.sh` shows the differences). Never put rules for one
plugin here; they go in its `AGENTS.md`.
Names below are placeholders. Each plugin's `AGENTS.md` gives its values:
| Placeholder | Meaning | Example |
|---|---|---|
| `{slug}` | Folder, main file and text domain | `my-plugin` |
| `{prefix}` | Option, hook, function and file prefix: `{PREFIX}` in lower case | `myplugin` |
| `{Prefix}` | Class prefix (`@package`) | `MyPlugin` |
| `{PREFIX}` | Constant prefix | `MYPLUGIN` |
| `{Name}` | Plugin name | My Plugin |
| `{css}` | CSS class and data attribute prefix (`{css}-card`, `data-{css}-setting`) | `mp` |
Minimums: **WordPress 6.2, PHP 7.4** (`readme.txt`, plugin header).
How changes are made and checked: `DEVELOPMENT.md`. Releases: `RELEASING.md`.
## Structure
- Core files are listed in `scripts/core-files.txt`: the feature registry
(`includes/class-{prefix}.php`), the base feature
(`includes/class-{prefix}-feature.php`), the settings store
(`includes/class-{prefix}-settings.php`), the admin screen and Read Me tab
(`admin/`), the shared GitHub updater (`includes/github-updater/`), the
scripts, the CI workflow and tool configuration, and the shared docs (this
file, `DEVELOPMENT.md`, `RELEASING.md`, `CONTRIBUTING.md`, `SECURITY.md`).
A plugin's copy differs from the starter's only in the names above. They
hold no code for one plugin: they read `{Prefix}_Setup`
(`includes/class-{prefix}-setup.php`: features, settings tabs, header
links, settings version and history, the plugin's own helpers and admin
parts) or use hooks (`{prefix}_admin_tabs` for tabs,
`{prefix}_admin_enqueue` for the plugin's own admin CSS and JS,
`scripts/preflight-plugin.sh` for its own release checks). Anything only
one plugin needs goes there, in a feature, or in its own file loaded from
there. To change a core file, change the starter first, then run
`scripts/sync-core.sh` in each plugin; `scripts/sync-core.sh --check` lists
core files that differ. A new plugin starts as a copy of the starter
renamed with `scripts/rename-plugin.sh`.
- One class per feature in `includes/features/`, extending `{Prefix}_Feature`,
registered in `{Prefix}_Setup::FEATURES`. Features some builds leave out
go in `{Prefix}_Setup::OPTIONAL_FEATURES` and load only when present.
- `settings()` declares the schema; the admin screen renders, searches and
saves it with no extra code. Field types and keys: `README.md` → Developers.
- `boot()` returns early unless `self::enabled()`. Features are **off by
default**; the plugin's `AGENTS.md` lists any the owner asked to be on.
Turning another feature on by default needs the owner's say.
- A feature that replaces another plugin sets `'replaces' => array(slug => name)`
and imports that plugin's settings in `migrate()` with
`self::import_setting()` (fills only unset keys). It never writes or
deletes the other plugin's options. While that plugin is active the
feature waits, and the Plugins screen suggests deactivating and deleting
it (`admin/includes/class-replaced-plugins.php`) with no extra code.
- Migrations run once per `{Prefix}_Setup::DB_VERSION`. After a release, a
new or changed import needs a version bump and a line in its docblock.
- New options, post meta, user meta, transients, cron hooks and files must be
removed in `uninstall.php`.
- `README.md` is also the plugin's Read Me tab
(`admin/includes/class-readme-manager.php`), which renders headings, lists,
tables, bold, italic, inline code, links (http(s) and `#heading` links,
with GitHub-style heading IDs) and images from the plugin folder on a line
of their own (`![alt](admin/images/banner.svg)`). Use only that Markdown,
or extend the renderer in the same change.
- Update `README.md` (feature section, hooks, changelog), `changelog.txt`
(the user-facing changelog entry) and `readme.txt` in the same change.
`readme.txt` must stay under 10 KB for WordPress.org: one short line per
feature, every service the plugin contacts under External services, and
only the newest version's changelog, in short. Details go in `README.md`.
- `.distignore` lists files kept out of the release zip. Add new
development-only files there (the preflight fails when a known one gets in),
then check the build with Plugin Check.
## Code rules
- PHP 7.4 syntax and WordPress 6.2 APIs. Guard newer core APIs with
`function_exists()` or `method_exists()`.
- Capability and nonce checks on every admin action and AJAX handler; escape on
output; sanitise through the schema.
- Prefix everything global with `{prefix}_`, `{Prefix}_` or `{PREFIX}_`. The
shared GitHub updater is the one exception: its `wpallstars_` names are the
same in every plugin, so that one copy can stand in for the others.
- Admin copy: short, plain words, sentence case, no jargon.
- Decide for the user. Within a feature, the plugin makes the choices
(which plugins, screens or items it applies to) from what it can detect.
Settings are there to bypass something that causes a problem, not choices
people need to understand first. A new setting must earn its place;
prefer detecting the right behaviour plus a short bypass list.
- Site owner in control, performance first: the owner decides what their site
sends, contacts and shows. Calls to outside services are opt-in where they
are not the point of the feature, made only as often and for as long as
needed (cache answers, never on every page load), and never block a
visitor's page when they can run later. Features that rein in other
plugins hand the choice to the owner instead of deciding for them.
WordPress update checks and downloads are the exception: leave them alone
(next rule).
- Do not change WordPress update behaviour (update transients, `auto_update_*`
filters, update checks) outside the shared GitHub updater. Plugin Check
reports `plugin_updater_detected` as an error, and WordPress.org asks plugins
not to interfere with the updater.
- Leave no PHP errors, warnings, notices or deprecations behind. Fix any that
the plugin causes as you find them, in the same change when it is small,
or as a tracked issue. That includes ones in other plugins that only happen
because of this one. Messages that other plugins cause on their own are
theirs: mention them, do not hide them.
## Updates from GitHub
The one exception to the update rule, at the owner's request (for speed,
reliability and site owners' control, and because the plugins are released
on GitHub first): the shared GitHub updater in `includes/github-updater/`.
It replaces Git Updater.
- Every plugin made from the starter carries a copy. Each copy registers its
version from `load.php` when its plugin loads; on `plugins_loaded` only the
newest copy loads, once, and serves every installed plugin with a
`GitHub Plugin URI` header. One update check covers them all.
- It adds GitHub releases of those plugins to core's own update check and
`plugins_api`, and leaves the download, install, auto-updates and rollback
to core. It only adds entries for those plugins; it never removes or blocks
other updates.
- It is the same in every plugin apart from its text domain and `@package`.
Change it in the starter, raise the version in its `load.php`, and copy it
to each plugin. Plugins change what it does only through its filters
(`wpallstars_github_updater_enabled`, `wpallstars_github_updater_early`,
`wpallstars_github_plugins`, `wpallstars_github_token`), never by calling
its class: another plugin's copy may be the one that runs.
- Keep anything that installs or updates code from outside WordPress.org in
that folder (and in a feature listed in `.distignore-wporg`, when a plugin
has a setting for it), because the WordPress.org build leaves them out.
- Never add an `Update URI` header. Tokens for private repositories come only
from `wp-config.php` (`WPALLSTARS_GITHUB_TOKEN`) or the filter, go only to
api.github.com and are never stored.
## Releases
GitHub releases are the early channel; WordPress.org gets settled versions.
Sites install the latest GitHub release whose tag is a plain version and the
asset whose name starts with the plugin folder (the shared updater; Git
Updater, where still active, reads `Version:` on `main` instead), so:
- Publish the GitHub release (tag `vX.Y.Z`, asset `{slug}-X.Y.Z.zip` with a
`{slug}/` folder, built with `.distignore`) straight after the version
change reaches `main`.
- Never put a pre-release version (`-beta1`, `-rc1`) in `Version:` on `main`;
mark test releases as pre-releases on GitHub.
- The WordPress.org build is the release build without the files in
`.distignore-wporg` (the GitHub updater) and the `GitHub Plugin URI`,
`Primary Branch` and `Release Asset` header lines. Its zip is named
`wordpress-org-{slug}-X.Y.Z.zip` so no updater picks it; never attach it
to a GitHub release.
- Any plugin released on GitHub (made from the starter or not) follows the
same pattern: a `GitHub Plugin URI: owner/repo` header (and
`Release Asset: true`), plain version tags, and a `{folder}-X.Y.Z.zip`
asset with a `{folder}/` inside.
- Build both zips with `scripts/build-release.sh`, check them with
`scripts/preflight-release.sh` and `scripts/plugin-check.sh`. None of them
tags, publishes or uploads anything.
- Releasing and submitting to WordPress.org need the owner's say. A private
repository cannot be read by sites without a token
(`WPALLSTARS_GITHUB_TOKEN`).
Details: `RELEASING.md`; the plugin's own submission state: `LAUNCH.md`.
## Front-end styling and dark mode
Block, shortcode and other front-end styles must work with the Kadence Pro
dark mode switcher (and themes that switch palettes the same way).
- How it switches: Kadence adds `color-switch-dark` or `color-switch-light` to
`<body>`. The dark class sets `color-scheme: dark` and redefines
`--global-palette1`…`15` and `--wp--preset--color--theme-palette-N` **on
`<body>`**. `<html>` stays `color-scheme: light`. Palette 3 is the strongest
text and palette 9 the page background in light mode; dark mode swaps them.
- Use `currentColor`, `inherit`, translucent neutrals (for example
`rgba(127, 127, 127, 0.12)`) or palette variables, never fixed light or dark
colours for text, backgrounds or borders.
- Do not use `@media (prefers-color-scheme)` to follow the site: it tracks the
visitor's system, not the switcher.
- Do not define custom properties on `:root` from palette variables; they
resolve above `<body>` and keep the light values. Read palette variables
where they are used, or define derived ones on the block.
- Preset references (`var:preset|color|theme-palette3`) become CSS variables
with kebab-cased slugs, as core does: `--wp--preset--color--theme-palette-3`.
Use `_wp_to_kebab_case()` in PHP and the same rule in editor JS (a hyphen
between letters and digits, lower case).
- Embedded pages (iframes) do not follow the switcher: they see the visitor's
system setting, and the browser paints their own background behind them, so
they stay readable in both modes. Do not make iframes transparent or tint them.
- Test light and dark with the Kadence theme by toggling the body class (see
Testing, step 5).
## Testing
CI (`.github/workflows/ci.yml`) runs the code checks, the release preflight,
Plugin Check and a smoke test on every pull request; details and local
commands: `DEVELOPMENT.md`.
While a repository is private, CI and review apps only advise: nothing is
required to merge, for speed. Fix failures your change causes before merging;
open an issue for any other failure and merge anyway. Do not turn on branch
protection, required checks or paid reviewers. At public launch (owner's say),
run the full sweep in `DEVELOPMENT.md` → At public launch, which makes the
checks required.
The checks catch errors, not wrong behaviour, so also verify on real
WordPress:
1. `scripts/lint.sh` (syntax, ShellCheck, PHPCS, PHPStan; run
`composer install` once). Fix findings in the code; never grow
`phpstan-baseline.neon` or add a `phpcs:ignore` without a reason.
2. The user reviews on one local test site per plugin, shared by every
session and worktree. It shows a **combined preview**: `origin/main` plus
every open pull request from the repository, merged together. Update it
only with the script, from any worktree:
```bash
scripts/preview-site.sh # the first run on a clone takes the site: scripts/preview-site.sh "<site>"
scripts/preview-site.sh --dry-run # report what would be included, copy nothing
```
It fetches `origin`, merges each open PR's branch onto `origin/main` in PR
order without touching any checkout, leaves out branches that conflict
(and lists them), copies the result with `.distignore` applied (exactly
what a release build contains), and writes
`<site>/wp-content/{slug}-synced-from.txt` listing what is included.
A lock stops two runs at once. Because every run includes everyone's
pushed work, no session hides another's.
- **Never** `rsync` your worktree into the shared site: it hides every
other session's work until the next run. Do not use Git hooks either:
they are shared by every worktree and would deploy the wrong checkout.
- Only pushed work with an open PR is included. Before asking the user to
look at unmerged work, push the branch and open a draft PR, then run the
script. It says so if the branch you run it from is missing or has
commits that are not pushed.
- After merging a PR, run the script again, then check that the stamp
lists your merge in `main` before telling the user to look.
- Conflicts only in `changelog.txt`, `readme.txt` or `README.md` do not
leave a branch out: every PR adds lines at the top of the same
changelogs, so each merge to `main` would otherwise drop every other
open PR. The preview keeps both sides' lines there and says so in the
stamp; still merge `origin/main` into your branch before it merges.
- If your branch is left out because it conflicts in other files, merge
`origin/main` into it (or wait for the other PR), push and run the
script again. Tell the user which PRs are left out.
- To check your branch on its own, or for checks the user will not look
at, use a throwaway site of your own (step 4's Docker image on a free
port), which no one else overwrites:
`rsync -a --delete --delete-excluded --exclude-from=.distignore ./ "<site>/wp-content/plugins/{slug}/"`.
- Size every test site, shared or throwaway, as `DEVELOPMENT.md` → Test
site resources says. PHP's defaults fill up and the slowdowns look like
bugs.
3. Exercise the changed feature through the admin UI or HTTP, and check
the debug log for new messages mentioning `{slug}` or a plugin it affects
(`wp-content/debug.log`, or the file Debug Log Manager writes in
`wp-content/uploads/debug-log-manager/` when it is active). For settings
imports, seed the replaced plugin's options and delete `{prefix}_options`
and `{prefix}_db_version` while the plugin is inactive, then activate it.
4. For changes that touch core APIs, also smoke-test on WordPress 6.2 with
PHP 7.4 (`scripts/smoke-test.sh --wp 6.2 --php 7.4`, or the
`wordpress:php7.4-apache` Docker image with
`wp core download --version=6.2 --force`).
5. For front-end styling, view the page with the Kadence theme in light and
dark mode. Without Kadence Pro, simulate the switcher: print a
`body.color-switch-dark { color-scheme: dark; --global-palette1: …; }`
rule with a dark palette (palette 3 light, palette 9 dark, and the matching
`--wp--preset--color--theme-palette-N: var(--global-paletteN)` lines), then
swap the body class between `color-switch-light` and `color-switch-dark`.
Check text, backgrounds, borders and palette colours chosen in block
settings in both.
Note: since WordPress 5.6, posts restored from the Bin become drafts. Republish
test posts after bulk-trash tests.
+848
View File
@@ -0,0 +1,848 @@
/**
* WP Plugin Starter admin screen.
*
* Scoped to .wps-wrap. Uses core admin tokens so the screen follows the
* active admin colour scheme; core buttons, notices and plugin cards keep
* their native styles. Logical properties keep RTL working without a
* separate stylesheet.
*/
.wps-wrap {
--wps-accent: var(--wp-admin-theme-color, #2271b1);
--wps-accent-dark: var(--wp-admin-theme-color-darker-10, #135e96);
--wps-text: #1d2327;
--wps-muted: #646970;
--wps-border: #dcdcde;
--wps-surface: #fff;
--wps-subtle: #f6f7f7;
--wps-success: #008a20;
--wps-error: #d63638;
--wps-radius: 8px;
--wps-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
--wps-focus: 0 0 0 2px var(--wps-surface), 0 0 0 4px var(--wps-accent);
margin-block-start: 0;
margin-inline-start: -20px;
margin-inline-end: 0;
color: var(--wps-text);
/* Start below the Screen Options / Notices tabs, so they never narrow the header. */
clear: both;
}
.wps-wrap *,
.wps-wrap *::before,
.wps-wrap *::after {
box-sizing: border-box;
}
/* Header ------------------------------------------------------------- */
.wps-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 24px;
padding: 14px 24px;
background: var(--wps-surface);
border-block-end: 1px solid var(--wps-border);
}
.wps-header__brand {
display: flex;
align-items: center;
gap: 10px;
}
.wps-header__logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 20px;
color: #fff;
background: var(--wps-accent);
border-radius: 6px;
}
.wps-wrap .wps-header__title {
margin: 0;
padding: 0;
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.wps-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.wps-wrap .wps-header__support {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* line-height: 1 stops the icon taking the button's taller line height,
which draws the glyph below the middle of its box. */
.wps-wrap .wps-header__support .dashicons {
width: 18px;
height: 18px;
font-size: 18px;
line-height: 1;
}
/* Search ------------------------------------------------------------- */
/* Stretch, so the field always matches the button's height, whatever
size the admin colour scheme or WordPress version gives buttons. */
.wps-search {
display: flex;
align-items: stretch;
gap: 6px;
}
.wps-search__field {
position: relative;
display: flex;
align-items: stretch;
}
.wps-wrap .wps-search__icon {
position: absolute;
inset-block: 0;
inset-inline-start: 9px;
margin-block: auto;
color: var(--wps-muted);
pointer-events: none;
}
.wps-wrap .wps-search__input {
width: 260px;
max-width: 100%;
height: auto;
min-height: 30px;
margin: 0;
padding-block: 0;
padding-inline: 34px 10px;
font-size: 14px;
line-height: normal;
color: var(--wps-text);
background: var(--wps-surface);
/* Border and corners stay core's, so they match core buttons in each version. */
box-shadow: none;
transition: border-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
}
.wps-wrap .wps-search__input::placeholder {
color: var(--wps-muted);
opacity: 1;
}
.wps-wrap .wps-search__input:hover {
border-color: var(--wps-accent);
}
.wps-wrap .wps-search__input:focus {
border-color: var(--wps-accent);
box-shadow: 0 0 0 1px var(--wps-accent);
outline: 2px solid transparent;
}
.wps-wrap .wps-search__field:focus-within .wps-search__icon {
color: var(--wps-accent);
}
.wps-wrap .wps-search__button {
margin: 0;
}
.wps-wrap .wps-search__group {
margin: 24px 0 8px;
font-size: 14px;
font-weight: 600;
}
.wps-wrap .wps-search__group:first-of-type {
margin-block-start: 8px;
}
/* Navigation --------------------------------------------------------- */
.wps-nav {
display: flex;
flex-wrap: wrap;
align-items: stretch;
padding-inline: 12px;
background: var(--wps-surface);
border-block-end: 1px solid var(--wps-border);
overflow-x: auto;
}
.wps-nav__group {
display: flex;
align-items: stretch;
}
.wps-nav__group + .wps-nav__group {
margin-inline-start: 8px;
padding-inline-start: 8px;
border-inline-start: 1px solid var(--wps-border);
}
.wps-nav__tab {
display: inline-flex;
align-items: center;
padding: 14px 12px 11px;
font-size: 14px;
font-weight: 500;
color: var(--wps-muted);
text-decoration: none;
white-space: nowrap;
border-block-end: 3px solid transparent;
transition: color 0.1s ease, border-color 0.1s ease;
}
.wps-nav__tab:hover {
color: var(--wps-text);
}
.wps-nav__tab:focus {
outline: none;
box-shadow: none;
}
.wps-nav__tab:focus-visible {
outline: 2px solid var(--wps-accent);
outline-offset: -4px;
border-radius: 4px;
}
.wps-nav__tab.is-active {
color: var(--wps-text);
font-weight: 600;
border-block-end-color: var(--wps-accent);
}
/* Main --------------------------------------------------------------- */
.wps-wrap > .notice,
.wps-wrap > div.updated,
.wps-wrap > div.error {
margin: 16px 24px 0;
}
.wps-main {
max-width: 1200px;
padding: 24px;
}
.wps-section {
max-width: 860px;
}
.wps-section__intro {
margin-block-end: 16px;
}
.wps-wrap .wps-section__title {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
.wps-section__desc,
.wps-section__hint {
margin: 0;
color: var(--wps-muted);
}
.wps-section__hint {
margin-block-start: 4px;
font-size: 12px;
}
/* Cards -------------------------------------------------------------- */
.wps-card {
background: var(--wps-surface);
border: 1px solid var(--wps-border);
border-radius: var(--wps-radius);
box-shadow: var(--wps-shadow);
}
.wps-cards {
display: grid;
gap: 12px;
}
/* A settings tab with nothing in it yet. */
.wps-empty {
padding: 16px 20px;
color: var(--wps-muted);
}
.wps-empty p {
margin: 0;
}
.wps-badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
line-height: 1.6;
color: var(--wps-muted);
vertical-align: middle;
background: var(--wps-subtle);
border: 1px solid var(--wps-border);
border-radius: 999px;
}
.wps-badge--success {
color: #00450c;
background: #edfaef;
border-color: #b8e6bf;
}
/* Setting cards ------------------------------------------------------ */
/* Clip the header hover and panel backgrounds to the rounded corners. */
.wps-setting {
position: relative;
overflow: hidden;
}
/* "On" stripe drawn above the header/panel backgrounds so hover never hides it. */
.wps-setting::before {
content: "";
position: absolute;
z-index: 1;
inset-block: 0;
inset-inline-start: 0;
width: 3px;
background: var(--wps-accent);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.wps-setting.is-on::before {
opacity: 1;
}
.wps-setting__header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
padding: 16px 20px;
}
.wps-setting.has-panel .wps-setting__header {
cursor: pointer;
}
.wps-setting.has-panel .wps-setting__header:hover {
background: #fcfcfc;
}
.wps-setting__text {
min-width: 0;
}
/* Status sits beside the title so showing/hiding it never reflows the description. */
.wps-setting__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
min-height: 20px;
}
.wps-setting__title {
font-size: 14px;
font-weight: 600;
}
.wps-setting__desc {
margin: 2px 0 0;
color: var(--wps-muted);
}
.wps-replaces {
margin: 4px 0 0;
font-size: 12px;
color: var(--wps-muted);
}
.wps-replaces__active {
color: #996800;
}
.wps-wrap .wps-setting__expand {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
color: var(--wps-accent);
text-decoration: none;
border-radius: 4px;
}
.wps-wrap .wps-setting__expand:hover {
background: var(--wps-subtle);
}
.wps-wrap .wps-setting__expand:focus-visible {
outline: none;
box-shadow: var(--wps-focus);
}
.wps-setting__expand .dashicons {
transition: transform 0.15s ease;
}
.wps-setting__expand[aria-expanded="true"] .dashicons {
transform: rotate(180deg);
}
.wps-setting__panel {
padding: 4px 20px 8px;
border-block-start: 1px solid var(--wps-border);
background: #fcfcfc;
}
.wps-setting__panel[hidden] {
display: none;
}
/* Status a feature prints above its options (wpstarter_setting_panel). */
.wps-panel-note {
padding: 12px 0;
border-block-end: 1px solid #f0f0f1;
color: var(--wps-muted);
}
.wps-panel-note p {
margin: 0 0 6px;
}
.wps-panel-note p:last-child {
margin-block-end: 0;
}
.wps-panel-note--warning {
color: #1d2327;
}
.wps-panel-note--warning p::before {
content: "\f534";
margin-inline-end: 6px;
font: 16px/1 dashicons;
vertical-align: -3px;
color: #dba617;
}
.wps-media__preview {
flex: none;
width: 64px;
height: 64px;
object-fit: contain;
background: repeating-conic-gradient(#f0f0f1 0% 25%, #fff 0% 50%) 0 0 / 16px 16px;
border: 1px solid var(--wps-border);
border-radius: 4px;
}
.wps-code {
max-width: 100%;
margin: 6px 0 0;
padding: 10px 12px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
color: #1d2327;
background: #f6f7f7;
border: 1px solid var(--wps-border);
border-radius: 4px;
white-space: pre;
}
/* Fields ------------------------------------------------------------- */
.wps-field {
display: grid;
grid-template-columns: 200px 1fr;
gap: 8px 24px;
padding: 16px 0;
}
.wps-field + .wps-field {
border-block-start: 1px solid #f0f0f1;
}
.wps-field__label {
padding-block-start: 6px;
font-weight: 600;
}
.wps-field__control .description {
margin-block-start: 6px;
}
.wps-field__unit {
color: var(--wps-muted);
}
.wps-field__control textarea.large-text {
max-width: 480px;
}
.wps-media {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.wps-media__preview[hidden],
.wps-media__remove[hidden] {
display: none;
}
.wps-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 6px 20px;
margin: 0;
padding: 6px 0 0;
border: 0;
}
.wps-checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Long lists, such as every active plugin: columns that scroll. */
.wps-checkboxes.is-long {
display: block;
max-height: 320px;
overflow-y: auto;
columns: 16em auto;
column-gap: 20px;
padding: 6px 8px;
border: 1px solid #dcdcde;
border-radius: 4px;
}
.wps-checkboxes.is-long .wps-checkbox {
display: flex;
align-items: flex-start;
margin-block-end: 6px;
break-inside: avoid;
}
.wps-checkboxes.is-long .wps-checkbox input {
margin-block-start: 2px;
}
.wps-checkboxes__all {
display: block;
margin-block-end: 4px;
}
.wps-tokens {
display: block;
margin-block-start: 6px;
}
.wps-wrap .wps-token {
margin: 2px 4px 2px 0;
padding: 0;
cursor: pointer;
background: none;
border: 0;
}
.wps-token code {
display: inline-block;
padding: 1px 6px;
font-size: 12px;
background: var(--wps-subtle);
border: 1px solid var(--wps-border);
border-radius: 4px;
}
.wps-token:hover code,
.wps-token:focus-visible code {
color: var(--wps-accent);
border-color: var(--wps-accent);
}
.wps-token:focus {
outline: none;
}
/* Switch ------------------------------------------------------------- */
.wps-switch {
position: relative;
display: inline-block;
flex: none;
width: 36px;
height: 20px;
vertical-align: middle;
}
.wps-wrap .wps-switch__input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
opacity: 0;
}
.wps-switch__track {
position: absolute;
inset: 0;
background: #fff;
border: 1px solid #1e1e1e;
border-radius: 999px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.wps-switch__track::after {
content: "";
position: absolute;
inset-block-start: 3px;
inset-inline-start: 3px;
width: 12px;
height: 12px;
background: #1e1e1e;
border-radius: 50%;
transition: transform 0.15s ease, background-color 0.15s ease;
}
.wps-switch__input:checked + .wps-switch__track {
background: var(--wps-accent);
border-color: var(--wps-accent);
}
.wps-switch__input:checked + .wps-switch__track::after {
background: #fff;
transform: translateX(16px);
}
[dir="rtl"] .wps-switch__input:checked + .wps-switch__track::after {
transform: translateX(-16px);
}
.wps-switch__input:focus-visible + .wps-switch__track {
box-shadow: var(--wps-focus);
}
.wps-switch__input:disabled + .wps-switch__track {
opacity: 0.5;
}
/* Save status -------------------------------------------------------- */
.wps-status {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 20px;
font-size: 12px;
color: var(--wps-muted);
white-space: nowrap;
}
.wps-field__control .wps-status {
margin-inline-start: 8px;
}
.wps-status.is-saving::before {
content: "";
width: 12px;
height: 12px;
border: 2px solid var(--wps-border);
border-block-start-color: var(--wps-accent);
border-radius: 50%;
animation: wps-spin 0.8s linear infinite;
}
.wps-status.is-saved {
color: var(--wps-success);
}
.wps-status.is-saved::before {
content: "\f147";
font: normal 18px/1 dashicons;
}
.wps-status.is-error {
color: var(--wps-error);
white-space: normal;
}
@keyframes wps-spin {
to {
transform: rotate(360deg);
}
}
/* Read Me ------------------------------------------------------------ */
.wps-readme {
max-width: 820px;
padding: 24px 32px;
font-size: 14px;
line-height: 1.65;
}
.wps-readme-image {
margin: 0 0 16px;
}
.wps-readme-image img {
display: block;
max-width: 100%;
height: auto;
border-radius: var(--wps-radius);
}
/* The banner at the top of README.md fills the top of the card. */
.wps-readme > .wps-readme-image:first-child {
margin: -24px -32px 24px;
}
.wps-readme > .wps-readme-image:first-child img {
width: 100%;
border-radius: calc(var(--wps-radius) - 1px) calc(var(--wps-radius) - 1px) 0 0;
}
.wps-wrap .wps-readme h2 {
margin: 0 0 8px;
font-size: 22px;
}
.wps-wrap .wps-readme h3 {
margin: 24px 0 8px;
padding-block-start: 16px;
font-size: 16px;
border-block-start: 1px solid #f0f0f1;
}
.wps-wrap .wps-readme h4 {
margin: 16px 0 6px;
font-size: 14px;
}
.wps-readme ul,
.wps-readme ol {
margin: 8px 0 12px;
padding-inline-start: 22px;
}
.wps-readme ul {
list-style: disc;
}
.wps-readme ol {
list-style: decimal;
}
.wps-readme-table {
margin: 8px 0 16px;
overflow-x: auto;
}
.wps-readme table {
border-collapse: collapse;
font-size: 13px;
line-height: 1.5;
}
.wps-readme th,
.wps-readme td {
padding: 6px 10px;
text-align: start;
vertical-align: top;
border: 1px solid rgba(127, 127, 127, 0.25);
}
.wps-readme th {
background: rgba(127, 127, 127, 0.08);
}
/* Responsive --------------------------------------------------------- */
@media screen and (max-width: 782px) {
.wps-wrap {
margin-inline-start: -10px;
}
.wps-header,
.wps-main {
padding-inline: 12px;
}
.wps-nav {
flex-wrap: nowrap;
}
/* Brand and website link on the first row, search across the second. */
.wps-search {
order: 1;
flex: 1 1 100%;
min-width: 0;
}
.wps-search__field {
flex: 1 1 0;
min-width: 0;
}
.wps-wrap .wps-search__input {
flex: 1 1 0;
width: auto;
min-width: 0;
min-height: 40px;
font-size: 16px;
}
.wps-search .button {
flex-shrink: 0;
}
.wps-setting__header {
grid-template-columns: auto 1fr;
}
.wps-setting__expand {
grid-column: 2;
justify-self: start;
}
.wps-field {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.wps-wrap *,
.wps-wrap *::before,
.wps-wrap *::after {
transition: none !important;
animation: none !important;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
/**
* Read Me content for the WP Plugin Starter admin tab (from README.md).
*
* @package WPStarter
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* README.md contents, with a short fallback if the file is missing.
*
* @return array{title:string,content:string}
*/
function wpstarter_get_readme_content() {
$readme_path = WPSTARTER_DIR . 'README.md';
if (is_readable($readme_path)) {
$content = (string) file_get_contents($readme_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file.
} else {
$content = "# WP Plugin Starter\n\nVersion: " . WPSTARTER_VERSION;
}
return array(
'title' => __('Read Me', 'wp-plugin-starter-template'),
'content' => $content,
);
}
+434
View File
@@ -0,0 +1,434 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
WP Plugin Starter banner (WordPress.org banners and the Read Me tab).
Source: .wordpress-org/banner.svg. Edit that, then run scripts/build-banner.sh.
Branding follows wpallstars.com: navy, red and cream, Zilla Slab italic
headings, the wpallstars plugin stack in red only, the logo's arc of five stars and its red double stripes (here with cream between).
-->
<svg
width="1544"
height="500"
viewBox="0 0 1544 500"
version="1.1"
id="svg51"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<title
id="title1">WPALLSTARS WP Plugin Starter: Built with AI</title>
<defs
id="defs21">
<linearGradient
id="bg"
x1="0"
y1="0"
x2="1"
y2="1">
<stop
offset="0"
stop-color="#000c1f"
id="stop1" />
<stop
offset="0.55"
stop-color="#001537"
id="stop2" />
<stop
offset="1"
stop-color="#0d264d"
id="stop3" />
</linearGradient>
<radialGradient
id="glow-blue"
cx="1214"
cy="270"
r="440"
gradientUnits="userSpaceOnUse">
<stop
offset="0"
stop-color="#bb0000"
stop-opacity="0.45"
id="stop4" />
<stop
offset="1"
stop-color="#bb0000"
stop-opacity="0"
id="stop5" />
</radialGradient>
<radialGradient
id="glow-red"
cx="200"
cy="580"
r="520"
gradientUnits="userSpaceOnUse">
<stop
offset="0"
stop-color="#bb0000"
stop-opacity="0.2"
id="stop6" />
<stop
offset="1"
stop-color="#bb0000"
stop-opacity="0"
id="stop7" />
</radialGradient>
<radialGradient
id="glow-gold"
cx="1214"
cy="76"
r="120"
gradientUnits="userSpaceOnUse">
<stop
offset="0"
stop-color="#ffc94d"
stop-opacity="0.45"
id="stop8" />
<stop
offset="1"
stop-color="#ffc94d"
stop-opacity="0"
id="stop9" />
</radialGradient>
<radialGradient
id="dots-fade"
cx="1214"
cy="260"
r="560"
gradientUnits="userSpaceOnUse">
<stop
offset="0"
stop-color="#fff"
stop-opacity="1"
id="stop10" />
<stop
offset="1"
stop-color="#fff"
stop-opacity="0"
id="stop11" />
</radialGradient>
<pattern
id="dots"
width="28"
height="28"
patternUnits="userSpaceOnUse">
<circle
cx="14"
cy="14"
r="1.5"
fill="#ffffea"
fill-opacity="0.12"
id="circle11" />
</pattern>
<mask
id="dots-mask">
<rect
width="1544"
height="500"
fill="url(#dots-fade)"
id="rect11" />
</mask>
<linearGradient
id="gold"
x1="0"
y1="0"
x2="0"
y2="1">
<stop
offset="0"
stop-color="#ffe08a"
id="stop12" />
<stop
offset="1"
stop-color="#f5a524"
id="stop13" />
</linearGradient>
<linearGradient
id="gold-text"
x1="96"
y1="280"
x2="700"
y2="340"
gradientUnits="userSpaceOnUse">
<stop
offset="0"
stop-color="#ffd970"
id="stop14" />
<stop
offset="1"
stop-color="#f5a524"
id="stop15" />
</linearGradient>
<linearGradient
id="top-red"
x1="0"
y1="0"
x2="1"
y2="1">
<stop
offset="0"
stop-color="#ef4444"
id="stop16" />
<stop
offset="1"
stop-color="#bb0000"
id="stop17" />
</linearGradient>
<linearGradient
id="top-cream"
x1="0"
y1="0"
x2="1"
y2="1">
<stop
offset="0"
stop-color="#ff8a8a"
id="stop18" />
<stop
offset="1"
stop-color="#e03131"
id="stop19" />
</linearGradient>
<linearGradient
id="top-blue"
x1="0"
y1="0"
x2="1"
y2="1">
<stop
offset="0"
stop-color="#c81e1e"
id="stop20" />
<stop
offset="1"
stop-color="#7a0000"
id="stop21" />
</linearGradient>
<!-- A five-pointed star, radius 1, centred on 0,0 -->
<polygon
id="star"
points="0,-1 0.2245,-0.309 0.9511,-0.309 0.3633,0.118 0.5878,0.809 0,0.382 -0.5878,0.809 -0.3633,0.118 -0.9511,-0.309 -0.2245,-0.309" />
</defs>
<!-- Background -->
<rect
width="1544"
height="500"
fill="url(#bg)"
id="rect21" />
<rect
width="1544"
height="500"
fill="url(#glow-red)"
id="rect22" />
<rect
width="1544"
height="500"
fill="url(#glow-blue)"
id="rect23" />
<rect
width="1544"
height="500"
fill="url(#dots)"
mask="url(#dots-mask)"
id="rect24" />
<!-- wpallstars.com's red double lines, with a cream line between them. -->
<g
id="g30">
<rect
y="0"
width="1544"
height="6"
fill="#bb0000"
id="rect25" />
<rect
y="6"
width="1544"
height="5"
fill="#ffffea"
id="rect26" />
<rect
y="11"
width="1544"
height="6"
fill="#bb0000"
id="rect27" />
<rect
y="483"
width="1544"
height="6"
fill="#bb0000"
id="rect28" />
<rect
y="489"
width="1544"
height="5"
fill="#ffffea"
id="rect29" />
<rect
y="494"
width="1544"
height="6"
fill="#bb0000"
id="rect30" />
</g>
<!-- The picture: scaled down a little so it keeps clear of the stripes -->
<g
transform="translate(1214 248) scale(0.86) translate(-1214 -248)"
id="g48">
<!-- The stack: red, cream and blue layers, each with a top face and two sides -->
<g
stroke-linejoin="round"
id="g43">
<g
transform="translate(0 350)"
id="g33">
<polygon
points="1064,0 1214,87 1214,117 1064,30"
fill="#5c0000"
id="polygon30" />
<polygon
points="1214,87 1364,0 1364,30 1214,117"
fill="#420000"
id="polygon31" />
<polygon
points="1214,-87 1364,0 1214,87 1064,0"
fill="url(#top-blue)"
id="polygon32" />
<polygon
points="1214,-61 1319,0 1214,61 1109,0"
fill="none"
stroke="#ffffff"
stroke-opacity="0.3"
stroke-width="2"
id="polygon33" />
</g>
<g
transform="translate(0 294)"
id="g37">
<polygon
points="1064,0 1214,87 1214,117 1064,30"
fill="#c22424"
id="polygon34" />
<polygon
points="1214,87 1364,0 1364,30 1214,117"
fill="#9e1414"
id="polygon35" />
<polygon
points="1214,-87 1364,0 1214,87 1064,0"
fill="url(#top-cream)"
id="polygon36" />
<polygon
points="1214,-61 1319,0 1214,61 1109,0"
fill="none"
stroke="#001537"
stroke-opacity="0.18"
stroke-width="2"
id="polygon37" />
</g>
<g
transform="translate(0 238)"
id="g42">
<polygon
points="1064,0 1214,87 1214,117 1064,30"
fill="#940000"
id="polygon38" />
<polygon
points="1214,87 1364,0 1364,30 1214,117"
fill="#700000"
id="polygon39" />
<polygon
points="1214,-87 1364,0 1214,87 1064,0"
fill="url(#top-red)"
id="polygon40" />
<!-- The WordPress Plugins icon (Dashicons admin-plugins, GPLv2 or later),
laid flat on the top face: icon x runs along the top-right edge and
icon y along the top-left edge, each 20-unit side 66% of the face.
Turned 45 degrees so the plug points along the back-left edge. -->
<g
transform="matrix(4.95 2.871 -4.95 2.871 1214 -57.42)"
id="g41">
<path
fill="#5c0000"
fill-opacity="0.55"
transform="translate(0.3 0.3) rotate(-45 10 10)"
d="M13.11 4.36L9.87 7.6 8 5.73l3.24-3.24c.35-.34 1.05-.2 1.56.32.52.51.66 1.21.31 1.55zm-8 1.77l.91-1.12 9.01 9.01-1.19.84c-.71.71-2.63 1.16-3.82 1.16H6.14L4.9 17.26c-.59.59-1.54.59-2.12 0-.59-.58-.59-1.53 0-2.12l1.24-1.24v-3.88c0-1.13.4-3.19 1.09-3.89zm7.26 3.97l3.24-3.24c.34-.35 1.04-.21 1.55.31.52.51.66 1.21.31 1.55l-3.24 3.25z"
id="path40" />
<path
fill="#ffffea"
transform="rotate(-45 10 10)"
d="M13.11 4.36L9.87 7.6 8 5.73l3.24-3.24c.35-.34 1.05-.2 1.56.32.52.51.66 1.21.31 1.55zm-8 1.77l.91-1.12 9.01 9.01-1.19.84c-.71.71-2.63 1.16-3.82 1.16H6.14L4.9 17.26c-.59.59-1.54.59-2.12 0-.59-.58-.59-1.53 0-2.12l1.24-1.24v-3.88c0-1.13.4-3.19 1.09-3.89zm7.26 3.97l3.24-3.24c.34-.35 1.04-.21 1.55.31.52.51.66 1.21.31 1.55l-3.24 3.25z"
id="path41" />
</g>
</g>
</g>
<!-- The logo's arc of five stars: gold in the middle, floating above the stack -->
<circle
cx="1214"
cy="76"
r="120"
fill="url(#glow-gold)"
id="circle43" />
<g
stroke-linejoin="round"
id="g47">
<g
fill="#bb0000"
stroke="#ffffea"
stroke-width="5"
id="g46">
<use
xlink:href="#star"
transform="translate(1043 150) scale(17)"
stroke-width="0.3"
id="use43" />
<use
xlink:href="#star"
transform="translate(1110 100) scale(26)"
stroke-width="0.2"
id="use44" />
<use
xlink:href="#star"
transform="translate(1318 100) scale(26)"
stroke-width="0.2"
id="use45" />
<use
xlink:href="#star"
transform="translate(1385 150) scale(17)"
stroke-width="0.3"
id="use46" />
</g>
<use
xlink:href="#star"
transform="translate(1214 78) scale(54)"
fill="url(#gold)"
stroke="#ffffea"
stroke-width="0.11"
id="use47" />
</g>
</g>
<!-- Words -->
<g
font-family="Zilla Slab"
text-rendering="geometricPrecision"
id="g51">
<path
d="m 123.893,113.015 h -1.431 L 117.683,128 h -2.214 l -4.293,-12.366 h -0.135 L 106.883,128 h -2.241 l -5.076,-14.985 h -1.512 v -2.565 h 6.669 v 2.565 h -1.944 l 3.024,10.044 h 0.135 l 3.483,-10.044 h -1.539 v -2.565 h 6.642 v 2.565 h -1.431 l 3.483,10.179 h 0.135 l 2.754,-10.179 h -2.16 v -2.565 h 6.588 z m 20.60601,3.024 q 0,2.889 -1.863,4.374 -1.836,1.458 -4.428,1.458 h -2.97 v 3.564 h 2.808 V 128 h -7.83 v -2.565 h 2.025 v -12.42 h -2.025 v -2.565 h 7.83 q 3.267,0 4.86,1.566 1.593,1.566 1.593,4.023 z m -3.213,0.054 q 0,-1.458 -0.837,-2.268 -0.81,-0.81 -2.754,-0.81 h -2.457 v 6.318 h 2.457 q 1.917,0 2.754,-0.864 0.837,-0.891 0.837,-2.376 z M 166.72505,128 h -6.723 v -2.565 h 1.782 l -0.891,-2.403 h -6.561 l -0.945,2.403 h 1.971 V 128 h -6.642 v -2.565 h 1.62 l 5.292,-12.42 h -2.565 v -2.565 h 5.643 l 6.318,14.985 h 1.701 z m -6.642,-7.155 -2.295,-6.102 h -0.162 l -2.43,6.102 z M 185.71107,128 h -12.987 v -2.565 h 2.025 v -12.42 h -2.025 v -2.565 h 7.641 v 2.565 h -2.619 v 12.42 h 5.4 l 0.54,-3.645 2.565,0.216 z m 19.93099,0 h -12.987 v -2.565 h 2.025 v -12.42 h -2.025 v -2.565 h 7.641 v 2.565 h -2.619 v 12.42 h 5.4 l 0.54,-3.645 2.565,0.216 z m 19.68799,-5.157 q 0,2.619 -1.836,4.023 -1.809,1.404 -4.806,1.404 -1.782,0 -3.456,-0.432 -1.647,-0.432 -2.619,-1.242 l 0.486,-4.023 2.403,0.243 -0.054,2.16 q 0.891,0.459 1.62,0.594 0.756,0.108 1.404,0.108 1.566,0 2.592,-0.486 1.053,-0.513 1.053,-2.025 0,-1.647 -1.188,-2.187 -1.188,-0.567 -2.727,-0.864 -1.242,-0.243 -2.511,-0.648 -1.269,-0.405 -2.106,-1.35 -0.837,-0.945 -0.837,-2.781 0,-2.754 1.863,-3.942 1.89,-1.215 4.212,-1.215 1.62,0 3.132,0.486 1.539,0.486 2.403,1.269 l 0.162,3.699 -2.403,0.216 -0.243,-2.268 q -0.756,-0.432 -1.512,-0.594 -0.756,-0.162 -1.458,-0.162 -1.269,0 -2.106,0.513 -0.837,0.513 -0.837,1.647 0,1.323 1.026,1.809 1.053,0.459 2.349,0.756 1.107,0.243 2.511,0.648 1.404,0.405 2.43,1.458 1.053,1.026 1.053,3.186 z m 22.65801,-6.669 -2.592,0.216 -0.54,-3.375 h -3.429 v 12.42 h 2.943 V 128 h -8.775 v -2.565 h 2.835 v -12.42 h -3.456 l -0.54,3.375 -2.565,-0.216 0.54,-5.724 h 15.039 z M 270.18705,128 h -6.723 v -2.565 h 1.782 l -0.891,-2.403 h -6.561 l -0.945,2.403 h 1.971 V 128 h -6.642 v -2.565 h 1.62 l 5.292,-12.42 h -2.565 v -2.565 h 5.643 l 6.318,14.985 h 1.701 z m -6.642,-7.155 -2.295,-6.102 h -0.162 l -2.43,6.102 z M 291.81908,128 h -4.104 l -3.618,-6.939 h -2.889 v 4.374 h 2.052 V 128 h -7.074 v -2.565 h 2.025 v -12.42 h -2.025 v -2.565 h 7.803 q 3.267,0 4.887,1.377 1.62,1.35 1.62,3.807 0,1.755 -0.945,3.024 -0.918,1.242 -2.565,1.782 l 2.673,4.995 h 2.16 z m -4.509,-12.285 q 0,-1.485 -0.864,-2.079 -0.864,-0.621 -2.943,-0.621 h -2.295 v 5.643 h 2.268 q 2.16,0 2.997,-0.756 0.837,-0.783 0.837,-2.187 z m 23.62998,7.128 q 0,2.619 -1.836,4.023 -1.809,1.404 -4.806,1.404 -1.782,0 -3.456,-0.432 -1.647,-0.432 -2.619,-1.242 l 0.486,-4.023 2.403,0.243 -0.054,2.16 q 0.891,0.459 1.62,0.594 0.756,0.108 1.404,0.108 1.566,0 2.592,-0.486 1.053,-0.513 1.053,-2.025 0,-1.647 -1.188,-2.187 -1.188,-0.567 -2.727,-0.864 -1.242,-0.243 -2.511,-0.648 -1.269,-0.405 -2.106,-1.35 -0.837,-0.945 -0.837,-2.781 0,-2.754 1.863,-3.942 1.89,-1.215 4.212,-1.215 1.62,0 3.132,0.486 1.539,0.486 2.403,1.269 l 0.162,3.699 -2.403,0.216 -0.243,-2.268 q -0.756,-0.432 -1.512,-0.594 -0.756,-0.162 -1.458,-0.162 -1.269,0 -2.106,0.513 -0.837,0.513 -0.837,1.647 0,1.323 1.026,1.809 1.053,0.459 2.349,0.756 1.107,0.243 2.511,0.648 1.404,0.405 2.43,1.458 1.053,1.026 1.053,3.186 z"
id="text48"
style="font-weight:600;font-size:27px;letter-spacing:5;fill:#ff999b"
aria-label="WPALLSTARS" />
<path
d="M 192.464,184.256 H 187.16 L 163.552,240 h -9.464 l -10.712,-43.68 h -0.52 L 123.72,240 h -9.672 L 100.944,184.256 H 95.328 L 96.576,172.4 h 25.792 l -1.248,11.856 h -5.928 l 6.24,32.656 h 0.624 l 14.976,-32.656 h -5.096 l 1.248,-11.856 h 25.896 l -1.248,11.856 h -4.68 l 8.008,33.28 h 0.624 l 11.96,-33.28 h -6.864 l 1.248,-11.856 h 25.584 z m -0.37598,43.888 h 6.968 l 4.576,-43.888 h -6.968 l 1.248,-11.856 h 29.744 q 11.128,0 16.848,5.616 5.72,5.512 5.72,14.976 0,12.272 -8.216,18.304 -8.112,6.032 -18.408,6.032 h -9.984 l -1.144,10.816 h 9.984 l -1.248,11.856 h -30.368 z m 30.264,-22.568 q 7.696,0 10.504,-3.432 2.912,-3.536 2.912,-8.528 0,-4.576 -2.392,-6.968 -2.392,-2.392 -8.632,-2.392 h -7.696 l -2.184,21.32 z m 46.464,22.568 h 6.968 l 4.576,-43.888 h -6.968 l 1.248,-11.856 h 29.744 q 11.128,0 16.848,5.616 5.72,5.512 5.72,14.976 0,12.272 -8.216,18.304 -8.112,6.032 -18.408,6.032 h -9.984 l -1.144,10.816 h 9.984 l -1.248,11.856 h -30.368 z m 30.264,-22.568 q 7.696,0 10.504,-3.432 2.912,-3.536 2.912,-8.528 0,-4.576 -2.392,-6.968 -2.392,-2.392 -8.632,-2.392 h -7.696 l -2.184,21.32 z m 31.55191,-34.32 3.224,-2.808 h 16.432 l -7.488,61.36 h 6.032 l -0.936,7.592 -3.12,2.6 h -16.12 l 7.488,-61.36 h -6.448 z m 73.98399,58.552 -0.936,7.384 -3.224,2.808 h -14.456 l -0.52,-9.048 q -2.392,4.472 -6.136,7.28 -3.744,2.704 -8.944,2.704 -6.344,0 -10.608,-3.952 -4.16,-3.952 -3.12,-12.272 l 2.6,-21.424 h -6.032 l 0.936,-7.592 3.12,-2.6 h 16.016 l -3.224,26.416 q -0.728,6.136 0.832,8.736 1.664,2.496 5.512,2.496 4.68,0 6.968,-3.12 2.392,-3.12 3.016,-8.008 v 0.104 l 3.224,-26.624 h 12.896 l -4.472,36.712 z m 41.32793,-36.712 h 16.952 l -0.936,7.592 -3.12,2.6 h -3.328 l -4.264,34.32 q -1.248,9.984 -6.864,17.68 -5.616,7.8 -18.616,7.8 -6.656,0 -12.584,-3.328 -5.824,-3.224 -7.592,-11.336 l 9.152,-5.2 q 3.432,8.736 11.856,8.736 9.984,0 11.752,-14.352 l 0.728,-6.448 q -2.392,4.056 -6.24,6.968 -3.848,2.912 -9.36,2.912 -7.696,0 -11.336,-5.824 -3.64,-5.824 -3.64,-13.936 0,-6.552 2.392,-13.208 2.392,-6.76 7.176,-11.336 4.888,-4.576 12.48,-4.576 10.088,0 13.104,8.32 z m -13.312,9.256 q -4.992,0 -7.592,5.2 -2.6,5.2 -2.6,12.272 0,4.888 1.456,8.008 1.456,3.016 4.888,3.016 4.16,0 7.488,-3.952 3.328,-3.952 4.368,-8.424 l 0.624,-4.992 q 0.208,-5.408 -2.496,-8.216 -2.704,-2.912 -6.136,-2.912 z m 39.5601,-17.576 1.456,-13.936 h 12.48 l -1.456,13.936 z m -8.216,11.128 3.224,-2.808 h 16.536 l -4.472,36.712 h 5.928 l -0.936,7.592 -3.12,2.6 h -16.016 l 4.472,-36.712 h -6.552 z m 30.51203,-2.808 h 14.456 l 0.52,8.944 q 2.392,-4.472 6.136,-7.176 3.744,-2.704 8.944,-2.704 6.344,0 10.504,3.952 4.264,3.952 3.224,12.272 l -2.6,21.424 h 6.032 l -0.936,7.592 -3.12,2.6 h -16.016 l 3.224,-26.416 q 0.728,-6.032 -0.936,-8.632 -1.56,-2.6 -5.408,-2.6 -4.368,0 -6.76,2.704 -2.392,2.704 -3.12,7.176 l -3.328,27.768 h -12.896 l 4.472,-36.712 h -6.552 l 0.936,-7.384 z m 118.53613,25.688 q 0,10.816 -7.176,16.536 -7.072,5.72 -19.76,5.72 -6.968,0 -13.52,-1.768 -6.448,-1.768 -9.984,-5.304 l 3.848,-16.744 10.816,1.04 -0.936,8.216 q 3.224,1.872 5.616,2.288 2.392,0.312 3.744,0.312 5.824,0 9.152,-1.768 3.432,-1.872 3.432,-7.488 0,-5.096 -2.912,-6.552 -2.912,-1.56 -8.632,-2.6 -4.576,-0.832 -9.048,-2.496 -4.368,-1.768 -7.28,-5.304 -2.912,-3.64 -2.912,-10.4 0,-7.8 3.848,-12.376 3.952,-4.68 9.88,-6.656 6.032,-2.08 12.272,-2.08 6.864,0 12.48,2.08 5.72,1.976 8.632,5.408 l -0.936,14.664 -11.024,1.04 -0.104,-7.904 q -2.392,-1.768 -4.888,-2.392 -2.496,-0.624 -4.992,-0.624 -4.368,0 -7.488,1.664 -3.12,1.56 -3.12,5.408 0,4.056 3.328,5.616 3.328,1.56 7.696,2.496 4.368,0.936 8.944,2.704 4.68,1.664 7.8,5.616 3.224,3.952 3.224,11.648 z m 5.44796,-22.464 3.536,-3.224 h 3.016 l 1.248,-10.088 4.056,-4.16 h 9.36 l -1.768,14.248 h 14.04 l -0.936,6.864 -3.536,3.328 h -10.816 l -2.6,21.424 q -0.728,6.136 3.12,6.136 1.664,0 3.224,-1.456 1.664,-1.56 3.016,-6.968 l 9.048,3.016 q -1.976,8.112 -5.824,11.856 -3.848,3.744 -10.816,3.744 -7.488,0 -11.648,-3.848 -4.056,-3.952 -3.224,-11.232 l 2.808,-22.672 h -6.24 z m 85.11207,-3.224 q -1.352,4.264 -2.392,8.32 -0.936,4.056 -1.456,8.32 l -2.496,20.072 h 6.032 l -0.936,7.592 -3.12,2.6 h -14.04 l -0.416,-9.568 q -2.184,4.368 -5.928,7.488 -3.744,3.12 -9.464,3.12 -7.696,0 -11.544,-5.824 -3.848,-5.824 -3.848,-14.248 0,-6.344 2.288,-13 2.288,-6.656 6.968,-11.232 4.784,-4.576 12.376,-4.576 10.192,0 13.312,8.736 0.52,-1.976 0.936,-3.848 0.52,-1.976 1.248,-3.952 z m -25.896,9.256 q -4.992,0 -7.384,5.304 -2.392,5.2 -2.392,11.752 0,11.44 6.656,11.44 4.264,0 7.28,-4.056 3.016,-4.056 3.952,-8.632 l 0.416,-3.536 q 0.624,-5.824 -2.184,-9.048 -2.704,-3.224 -6.344,-3.224 z m 35.50396,-9.256 h 13.416 l 0.728,12.896 q 1.56,-4.888 4.264,-9.36 2.808,-4.472 7.072,-4.472 2.912,0 5.096,1.872 l -2.08,13.832 q -2.912,-1.56 -5.408,-1.56 -7.072,0 -8.736,7.28 l -2.288,16.224 h 8.944 l -0.936,7.384 -3.224,2.808 h -25.064 l 0.936,-7.384 3.224,-2.808 h 3.12 l 3.224,-26.52 h -6.448 l 0.936,-7.384 z m 31.13605,3.224 3.536,-3.224 h 3.016 l 1.248,-10.088 4.056,-4.16 h 9.36 l -1.768,14.2Line truncated
id="text49"
style="font-style:italic;font-weight:700;font-size:104px;letter-spacing:-1;fill:#ffffea"
aria-label="WP Plugin Starter" />
<path
d="m 124.944,279.2 q 5.76,0 9.144,2.736 3.456,2.664 3.456,7.272 0,3.816 -2.448,6.912 -2.376,3.096 -6.768,3.816 v 0.288 q 4.248,0.72 6.408,3.528 2.16,2.808 2.16,7.056 0,5.904 -4.104,10.584 Q 128.688,326 120.336,326 H 96.648 l 0.864,-8.208 h 4.824 l 3.168,-30.384 h -4.824 l 0.864,-8.208 z m -5.112,19.152 q 4.392,0 5.904,-1.944 1.584,-1.944 1.584,-4.248 0,-2.88 -1.656,-3.816 -1.584,-0.936 -4.032,-0.936 h -6.84 l -1.152,10.944 z m -1.08,19.44 q 8.208,0 8.208,-6.624 0,-5.328 -6.336,-5.328 h -7.776 l -1.224,11.952 z m 57.24003,1.152 -0.648,5.112 -2.232,1.944 h -10.008 l -0.36,-6.264 q -1.656,3.096 -4.248,5.04 -2.592,1.872 -6.192,1.872 -4.392,0 -7.344,-2.736 -2.88,-2.736 -2.16,-8.496 l 1.8,-14.832 h -4.176 l 0.648,-5.256 2.16,-1.8 h 11.088 l -2.232,18.288 q -0.504,4.248 0.576,6.048 1.152,1.728 3.816,1.728 3.24,0 4.824,-2.16 1.656,-2.16 2.088,-5.544 v 0.072 l 2.232,-18.432 h 8.928 l -3.096,25.416 z m 10.29594,-31.176 1.008,-9.648 h 8.64 l -1.008,9.648 z m -5.688,7.704 2.232,-1.944 h 11.448 l -3.096,25.416 h 4.104 l -0.648,5.256 -2.16,1.8 h -11.088 l 3.096,-25.416 h -4.536 z m 20.80802,-17.064 2.232,-1.944 h 11.376 l -5.184,42.48 h 4.176 l -0.648,5.256 -2.16,1.8 h -11.16 l 5.184,-42.48 h -4.464 z m 16.84799,17.352 2.448,-2.232 h 2.088 l 0.864,-6.984 2.808,-2.88 h 6.48 l -1.224,9.864 h 9.72 l -0.648,4.752 -2.448,2.304 h -7.488 l -1.8,14.832 q -0.504,4.248 2.16,4.248 1.152,0 2.232,-1.008 1.152,-1.08 2.088,-4.824 l 6.264,2.088 q -1.368,5.616 -4.032,8.208 -2.664,2.592 -7.488,2.592 -5.184,0 -8.064,-2.664 -2.808,-2.736 -2.232,-7.776 l 1.944,-15.696 h -4.32 z m 72.21606,19.584 h 0.36 q 3.312,-4.608 4.68,-7.92 1.44,-3.384 1.44,-6.912 0,-3.456 -1.44,-6.984 h 10.008 q 0.792,4.392 -0.216,9.072 -1.008,4.68 -4.824,9.792 -1.872,2.448 -5.328,6.48 -3.456,4.032 -6.624,7.128 h -3.744 l -4.752,-18.72 h -0.432 l -10.512,18.72 h -4.752 l -6.912,-25.416 h -3.168 l 0.648,-5.112 2.232,-1.944 h 14.616 l -0.648,5.112 -2.232,1.944 h -2.232 l 2.52,12.816 h 0.432 l 10.368,-19.872 h 5.328 z m 24.04794,-27.576 1.008,-9.648 h 8.64 l -1.008,9.648 z m -5.688,7.704 2.232,-1.944 h 11.448 l -3.096,25.416 h 4.104 l -0.648,5.256 -2.16,1.8 h -11.088 l 3.096,-25.416 h -4.536 z m 19.15202,0.288 2.448,-2.232 h 2.088 l 0.864,-6.984 2.808,-2.88 h 6.48 l -1.224,9.864 h 9.72 l -0.648,4.752 -2.448,2.304 h -7.488 l -1.8,14.832 q -0.504,4.248 2.16,4.248 1.152,0 2.232,-1.008 1.152,-1.08 2.088,-4.824 l 6.264,2.088 q -1.368,5.616 -4.032,8.208 -2.664,2.592 -7.488,2.592 -5.184,0 -8.064,-2.664 -2.808,-2.736 -2.232,-7.776 l 1.944,-15.696 h -4.32 z m 27.28803,-17.352 2.232,-1.944 h 11.088 l -2.736,22.536 q 1.584,-2.736 4.104,-4.392 2.52,-1.728 5.904,-1.728 4.392,0 7.272,2.736 2.952,2.736 2.232,8.496 l -1.8,14.832 h 4.176 l -0.648,5.256 -2.16,1.8 h -11.088 l 2.232,-18.288 q 0.576,-4.176 -0.576,-5.976 -1.152,-1.8 -3.816,-1.8 -3.096,0 -4.68,1.944 -1.584,1.944 -2.16,5.112 l -2.304,19.008 h -8.928 l 5.184,-42.48 h -4.176 z m 45.14409,39.384 h 4.104 l 15.984,-30.384 h -6.192 l 0.864,-8.208 h 15.408 l 12.168,38.592 h 4.248 l -0.864,8.208 h -17.856 l 0.864,-8.208 h 3.6 l -1.224,-4.752 h -15.264 l -2.304,4.752 h 4.176 l -0.864,8.208 h -17.712 z m 25.632,-24.84 -6.408,13.176 h 10.152 l -3.312,-13.176 z m 23.75999,24.84 h 4.824 l 3.168,-30.384 h -4.824 l 0.864,-8.208 h 18.936 l -0.864,8.208 h -4.896 l -3.168,30.384 h 4.896 l -0.864,8.208 h -18.936 z"
id="text50"
style="font-style:italic;font-weight:700;font-size:72px;fill:url(#gold-text)"
aria-label="Built with AI" />
<path
d="m 119.912,396 h -7.887 v -2.607 h 2.541 l -1.32,-3.564 H 104.6 l -1.419,3.564 h 2.805 V 396 h -7.821 v -2.607 h 1.98 l 6.765,-16.236 h -3.3 v -2.607 h 6.468 l 7.722,18.843 h 2.112 z m -7.524,-8.415 -3.267,-8.712 h -0.198 l -3.432,8.712 z m 27.852,3.3 q -0.264,1.947 -1.749,3.696 -1.452,1.749 -4.653,1.749 -3.102,0 -5.016,-1.914 -1.881,-1.947 -1.881,-5.544 0,-3.333 1.881,-5.61 1.881,-2.31 5.247,-2.31 1.386,0 2.673,0.33 1.287,0.33 2.442,1.155 l 0.594,3.828 -2.343,0.264 -0.528,-2.475 q -1.254,-0.726 -2.871,-0.726 -2.079,0 -3.069,1.518 -0.99,1.485 -0.99,3.861 0,2.475 1.056,3.861 1.056,1.386 3.102,1.386 3.069,0 3.663,-3.564 z m 9.24002,5.115 h -7.656 v -2.244 h 2.376 v -18.183 h -2.475 v -2.277 h 5.379 v 20.46 h 2.376 z m 15.27898,-4.356 q -0.231,1.914 -1.881,3.3 -1.65,1.386 -4.488,1.386 -3.201,0 -5.181,-1.914 -1.98,-1.914 -1.98,-5.544 0,-3.432 1.848,-5.676 1.848,-2.244 5.115,-2.244 3.036,0 4.719,1.881 1.683,1.881 1.716,4.554 0,0.891 -0.198,1.881 h -10.164 q 0.198,4.686 4.323,4.686 1.716,0 2.574,-0.825 0.891,-0.825 1.188,-1.98 z m -3.135,-4.323 q 0.132,-1.452 -0.726,-2.772 -0.825,-1.353 -2.772,-1.353 -1.815,0 -2.739,1.221 -0.891,1.188 -1.089,2.904 z m 6.73203,-4.95 q 1.353,-0.792 2.64,-1.089 1.32,-0.33 2.871,-0.33 2.508,0 3.96,1.221 1.485,1.221 1.485,3.762 v 6.831 q 0,1.452 1.155,1.452 0.462,0 0.891,-0.165 l 0.033,1.716 q -0.924,0.561 -2.145,0.561 -2.673,0 -2.838,-2.97 v -0.033 q -0.66,1.155 -1.914,2.079 -1.221,0.924 -3.003,0.924 -1.518,0 -3.036,-0.924 -1.485,-0.924 -1.485,-3.234 0,-2.772 2.178,-3.63 2.211,-0.891 4.719,-0.891 1.518,0 2.541,0.132 v -1.089 q 0,-1.452 -0.561,-2.475 -0.561,-1.023 -2.508,-1.023 -1.584,0 -2.673,0.561 l -0.561,2.409 -2.343,-0.264 z m 8.052,7.953 v -0.858 q -0.594,-0.066 -1.254,-0.099 -0.627,-0.066 -1.287,-0.066 -1.518,0 -2.706,0.495 -1.188,0.495 -1.188,2.046 0,1.188 0.726,1.716 0.759,0.528 1.716,0.528 1.419,0 2.64,-0.99 1.221,-0.99 1.353,-2.772 z M 200.72902,396 h -7.656 v -2.244 h 2.376 v -6.369 q 0,-2.244 -0.858,-3.168 -0.858,-0.957 -2.277,-0.957 -1.848,0 -2.937,1.221 -1.089,1.221 -1.155,2.838 v 6.435 h 2.376 V 396 h -7.656 v -2.244 h 2.376 v -10.23 h -2.475 v -2.277 h 5.379 v 2.508 q 1.551,-2.805 5.016,-2.805 2.145,0 3.63,1.32 1.485,1.32 1.485,4.125 v 7.359 h 2.376 z m 20.95501,-4.323 q 0,2.211 -1.848,3.432 -1.815,1.221 -4.62,1.221 -3.399,0 -5.775,-1.452 l 0.462,-3.564 2.277,0.231 -0.066,1.914 q 0.66,0.33 1.419,0.462 0.792,0.132 1.551,0.132 1.419,0 2.541,-0.528 1.155,-0.528 1.155,-1.65 0,-1.089 -0.924,-1.518 -0.924,-0.462 -2.31,-0.693 -1.353,-0.231 -2.706,-0.561 -1.353,-0.363 -2.277,-1.188 -0.924,-0.858 -0.924,-2.574 0,-2.475 1.815,-3.432 1.848,-0.957 3.927,-0.957 1.518,0 2.838,0.396 1.353,0.363 2.442,0.957 l 0.33,3.564 -2.31,0.264 -0.33,-2.31 q -1.353,-0.627 -2.838,-0.627 -1.221,0 -2.013,0.462 -0.792,0.462 -0.792,1.518 0,1.023 0.891,1.452 0.924,0.429 2.244,0.66 1.353,0.198 2.673,0.561 1.353,0.363 2.244,1.221 0.924,0.858 0.924,2.607 z m 13.62901,0.165 q -0.627,4.488 -4.95,4.488 -2.277,0 -3.564,-1.221 -1.254,-1.254 -1.254,-3.465 v -8.184 h -2.277 v -2.211 h 2.277 v -4.653 h 2.871 v 4.653 h 5.643 v 2.211 h -5.643 v 7.755 q 0,2.64 2.277,2.64 0.726,0 1.452,-0.462 0.726,-0.495 1.023,-2.244 z m 2.60698,-9.471 q 1.353,-0.792 2.64,-1.089 1.32,-0.33 2.871,-0.33 2.508,0 3.96,1.221 1.485,1.221 1.485,3.762 v 6.831 q 0,1.452 1.155,1.452 0.462,0 0.891,-0.165 l 0.033,1.716 q -0.924,0.561 -2.145,0.561 -2.673,0 -2.838,-2.97 v -0.033 q -0.66,1.155 -1.914,2.079 -1.221,0.924 -3.003,0.924 -1.518,0 -3.036,-0.924 -1.485,-0.924 -1.485,-3.234 0,-2.772 2.178,-3.63 2.211,-0.891 4.719,-0.891 1.518,0 2.541,0.132 v -1.089 q 0,-1.452 -0.561,-2.475 -0.561,-1.023 -2.508,-1.023 -1.584,0 -2.673,0.561 l -0.561,2.409 -2.343,-0.264 z m 8.052,7.953 v -0.858 q -0.594,-0.066 -1.254,-0.099 -0.627,-0.066 -1.287,-0.066 -1.518,0 -2.706,0.495 -1.188,0.495 -1.188,2.046 0,1.188 0.726,1.716 0.759,0.528 1.716,0.528 1.419,0 2.64,-0.99 1.221,-0.99 1.353,-2.772 z m 15.213,-9.306 q 1.551,0 2.64,0.792 l 0.594,4.125 -2.277,0.264 -0.561,-2.541 q -0.561,-0.231 -1.155,-0.231 -1.122,0 -1.881,0.957 -0.759,0.924 -0.759,2.442 v 6.93 h 3.168 V 396 h -8.448 v -2.244 h 2.376 v -10.23 h -2.475 v -2.277 h 5.181 v 2.706 q 0.429,-1.254 1.32,-2.079 0.924,-0.858 2.277,-0.858 z m 16.86302,10.824 q -0.627,4.488 -4.95,4.488 -2.277,0 -3.564,-1.221 -1.254,-1.254 -1.254,-3.465 v -8.184 h -2.277 v -2.211 h 2.277 v -4.653 h 2.871 v 4.653 h 5.643 v 2.211 h -5.643 v 7.755 q 0,2.64 2.277,2.64 0.726,0 1.452,-0.462 0.726,-0.495 1.023,-2.244 z m 15.80697,-18.876 q 1.023,0 1.947,0.231 0.957,0.231 1.782,0.891 l 0.594,3.597 -2.31,0.297 -0.627,-2.475 q -0.693,-0.231 -1.551,-0.231 -2.343,0 -2.343,3.036 v 2.937 h 3.795 v 2.211 h -3.795 v 10.296 h 3.102 V 396 h -8.382 v -2.244 h 2.409 V 383.46 h -2.046 v -2.211 h 2.046 v -2.607 q 0,-2.508 1.518,-4.092 1.518,-1.584 3.861,-1.584 z m 17.88599,15.675 q 0,3.399 -2.112,5.544 -2.079,2.145 -5.511,2.145 -3.366,0 -5.346,-2.079 -1.947,-2.079 -1.947,-5.478 0,-3.234 1.881,-5.544 1.881,-2.31 5.61,-2.31 3.729,0 5.577,2.31 1.848,2Line truncated
id="text51"
style="font-weight:500;font-size:33px;fill:#c2cad6"
aria-label="A clean start for WordPress plugins, made with aidevops." />
</g>
</svg>

After

Width:  |  Height:  |  Size: 42 KiB

+360
View File
@@ -0,0 +1,360 @@
<?php
/**
* WP Plugin Starter admin screen.
*
* Owns the Settings → WP Plugin Starter page: tab registry, page chrome and the
* single admin script/stylesheet. Tab content is delegated to the manager
* classes. Settings tabs and header links come from WPStarter_Setup; add
* other tabs with the `wpstarter_admin_tabs` filter.
*
* @package WPStarter
* @since 0.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
class WPStarter_Admin_Manager {
/** Menu/page slug. */
const PAGE = 'wp-plugin-starter-template';
/** Hook suffix returned by add_options_page(). */
const HOOK = 'settings_page_' . self::PAGE;
/**
* Register hooks (once).
*/
public static function init() {
add_action('admin_menu', array(__CLASS__, 'register_admin_menu'));
add_action('admin_enqueue_scripts', array(__CLASS__, 'enqueue_assets'));
add_filter('plugin_action_links_' . plugin_basename(WPSTARTER_FILE), array(__CLASS__, 'plugin_action_links'));
}
/** Slug of the search results screen (not shown in the navigation). */
const SEARCH = 'search';
/**
* Settings tabs, in navigation order (WPStarter_Setup::settings_tabs()).
*
* @return array<string,array{label:string,description:string}>
*/
public static function settings_tabs() {
return WPStarter_Setup::settings_tabs();
}
/**
* Registered tabs.
*
* @return array<string,array{label:string,group:string,render:callable}>
*/
public static function get_tabs() {
$tabs = array();
// Settings tabs without settings are hidden (they can be filled via
// the schema filter). With no settings at all, the first tab shows
// and says so, so a new plugin still has a settings screen.
$settings_tabs = self::settings_tabs();
$has_settings = false;
foreach (array_keys($settings_tabs) as $slug) {
if (WPStarter_Settings::fields_for_tab($slug)) {
$has_settings = true;
break;
}
}
foreach ($settings_tabs as $slug => $tab) {
if ($has_settings && !WPStarter_Settings::fields_for_tab($slug)) {
continue;
}
if (!$has_settings && $tabs) {
break;
}
$tabs[$slug] = array(
'label' => $tab['label'],
'group' => 'settings',
'render' => function () use ($slug, $tab) {
WPStarter_Settings_Manager::render_tab($slug, $tab['label'], $tab['description']);
},
);
}
$tabs += array(
'readme' => array(
'label' => __('Read Me', 'wp-plugin-starter-template'),
'group' => 'about',
'render' => array('WPStarter_Readme_Manager', 'display_tab_content'),
),
);
/**
* Filter the admin tabs.
*
* @param array $tabs Tabs keyed by slug: label, group (settings|discover|about),
* render callback, optional capability.
*/
$tabs = (array) apply_filters('wpstarter_admin_tabs', $tabs);
// Hide tabs the current user cannot use.
return array_filter($tabs, function ($tab) {
return empty($tab['capability']) || current_user_can($tab['capability']);
});
}
/**
* Active tab slug, falling back to the first registered tab.
*
* @return string
*/
public static function get_active_tab() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation.
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : '';
if (self::SEARCH === $tab && '' !== self::search_query()) {
return $tab;
}
$tabs = self::get_tabs();
$tab = WPStarter_Settings::resolve_tab($tab);
if (!isset($tabs[$tab])) {
$tab = (string) key($tabs);
}
return $tab;
}
/**
* Current settings search text.
*
* @return string
*/
public static function search_query() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only search.
$query = isset($_GET['s']) ? sanitize_text_field(wp_unslash($_GET['s'])) : '';
return trim(function_exists('mb_substr') ? mb_substr($query, 0, 100) : substr($query, 0, 100));
}
/**
* URL of a tab.
*
* @param string $tab Tab slug.
* @param array $args Extra query args.
* @return string
*/
public static function tab_url($tab, array $args = array()) {
return add_query_arg(array_merge(array('page' => self::PAGE, 'tab' => $tab), $args), admin_url('options-general.php'));
}
/**
* Add the Settings link on the Plugins screen.
*
* @param array $links Action links.
* @return array
*/
public static function plugin_action_links($links) {
array_unshift($links, sprintf('<a href="%s">%s</a>', esc_url(add_query_arg('page', self::PAGE, admin_url('options-general.php'))), esc_html__('Settings', 'wp-plugin-starter-template')));
return $links;
}
/**
* Register Settings → WP Plugin Starter.
*/
public static function register_admin_menu() {
add_options_page(
__('WP Plugin Starter', 'wp-plugin-starter-template'),
__('WP Plugin Starter', 'wp-plugin-starter-template'),
'manage_options',
self::PAGE,
array(__CLASS__, 'render_settings_page')
);
}
/**
* Enqueue the admin stylesheet and script on our screen only.
*
* @param string $hook Current admin page hook.
*/
public static function enqueue_assets($hook) {
if (self::HOOK !== $hook) {
return;
}
$tab = self::get_active_tab();
$css = file_exists(WPSTARTER_DIR . 'admin/css/wpstarter-admin.css') ? filemtime(WPSTARTER_DIR . 'admin/css/wpstarter-admin.css') : WPSTARTER_VERSION;
$js = file_exists(WPSTARTER_DIR . 'admin/js/wpstarter-admin.js') ? filemtime(WPSTARTER_DIR . 'admin/js/wpstarter-admin.js') : WPSTARTER_VERSION;
wp_enqueue_style('wpstarter-admin', WPSTARTER_URL . 'admin/css/wpstarter-admin.css', array('dashicons'), $css);
/**
* Filter the admin script's dependencies; enqueue what a tab needs.
*
* @param string[] $deps Script handles.
* @param string $tab Active tab.
*/
$deps = (array) apply_filters('wpstarter_admin_script_deps', array('jquery', 'wp-a11y', 'wp-i18n'), $tab);
if (self::shows_media_field($tab)) {
wp_enqueue_media();
}
wp_enqueue_script('wpstarter-admin', WPSTARTER_URL . 'admin/js/wpstarter-admin.js', $deps, $js, true);
wp_set_script_translations('wpstarter-admin', 'wp-plugin-starter-template');
/**
* Filter the data the admin script reads (wpstarterAdmin).
*
* @param array $data Script data.
* @param string $tab Active tab.
*/
wp_localize_script('wpstarter-admin', 'wpstarterAdmin', (array) apply_filters('wpstarter_admin_script_data', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce(WPStarter_Settings::NONCE),
'tab' => $tab,
'i18n' => array(
'saving' => __('Saving…', 'wp-plugin-starter-template'),
'saved' => __('Saved', 'wp-plugin-starter-template'),
'saveFailed' => __('Could not save. Please try again.', 'wp-plugin-starter-template'),
'chooseImage' => __('Choose a picture', 'wp-plugin-starter-template'),
'useImage' => __('Use this picture', 'wp-plugin-starter-template'),
),
), $tab));
/**
* Fires after the admin stylesheet and script are enqueued: enqueue
* the plugin's own, depending on 'wpstarter-admin'.
*
* @param string $tab Active tab.
*/
do_action('wpstarter_admin_enqueue', $tab);
}
/**
* Whether a tab (or the search results) may show a media field, which
* needs the media dialog.
*
* @param string $tab Tab slug.
* @return bool
*/
private static function shows_media_field($tab) {
$schema = WPStarter_Settings::schema();
foreach ($schema as $field) {
if (!isset($field['type']) || 'media' !== $field['type']) {
continue;
}
if (self::SEARCH === $tab) {
return true;
}
$field_tab = isset($field['tab']) ? $field['tab'] : '';
if (!$field_tab && isset($field['parent'], $schema[$field['parent']]['tab'])) {
$field_tab = $schema[$field['parent']]['tab'];
}
if ($field_tab === $tab) {
return true;
}
}
return false;
}
/**
* Render the page chrome and the active tab.
*/
public static function render_settings_page() {
if (!current_user_can('manage_options')) {
return;
}
$tabs = self::get_tabs();
$active = self::get_active_tab();
$links = WPStarter_Setup::header_links();
$groups = array(
'settings' => __('Settings', 'wp-plugin-starter-template'),
'discover' => __('Discover', 'wp-plugin-starter-template'),
'about' => __('About', 'wp-plugin-starter-template'),
);
?>
<div class="wrap wps-wrap">
<header class="wps-header">
<div class="wps-header__brand">
<span class="wps-header__logo dashicons dashicons-star-filled" aria-hidden="true"></span>
<h1 class="wps-header__title"><?php esc_html_e('WP Plugin Starter', 'wp-plugin-starter-template'); ?></h1>
<span class="wps-badge"><?php echo esc_html('v' . WPSTARTER_VERSION); ?></span>
</div>
<form class="wps-search" role="search" method="get" action="<?php echo esc_url(admin_url('options-general.php')); ?>">
<input type="hidden" name="page" value="<?php echo esc_attr(self::PAGE); ?>" />
<input type="hidden" name="tab" value="<?php echo esc_attr(self::SEARCH); ?>" />
<label class="screen-reader-text" for="wps-search-input"><?php esc_html_e('Search features', 'wp-plugin-starter-template'); ?></label>
<span class="wps-search__field">
<span class="wps-search__icon dashicons dashicons-search" aria-hidden="true"></span>
<input type="search"
id="wps-search-input"
class="wps-search__input"
name="s"
maxlength="100"
value="<?php echo esc_attr(self::search_query()); ?>"
placeholder="<?php esc_attr_e('Search features', 'wp-plugin-starter-template'); ?>" />
</span>
<button type="submit" class="button wps-search__button"><?php esc_html_e('Search', 'wp-plugin-starter-template'); ?></button>
</form>
<div class="wps-header__actions">
<?php if (!empty($links['website'])) : ?>
<a class="button" href="<?php echo esc_url($links['website']); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e('Visit website', 'wp-plugin-starter-template'); ?>
<span class="screen-reader-text"><?php esc_html_e('(opens in a new tab)', 'wp-plugin-starter-template'); ?></span>
</a>
<?php endif; ?>
<?php if (!empty($links['support'])) : ?>
<a class="button wps-header__support" href="<?php echo esc_url($links['support']); ?>" target="_blank" rel="noopener noreferrer">
<span class="dashicons dashicons-sos" aria-hidden="true"></span>
<?php esc_html_e('Report a problem', 'wp-plugin-starter-template'); ?>
<span class="screen-reader-text"><?php esc_html_e('(opens in a new tab)', 'wp-plugin-starter-template'); ?></span>
</a>
<?php endif; ?>
<?php if (!empty($links['donate'])) : ?>
<a class="button wps-header__support wps-header__donate" href="<?php echo esc_url($links['donate']); ?>" target="_blank" rel="noopener noreferrer">
<span class="dashicons dashicons-coffee" aria-hidden="true"></span>
<?php esc_html_e('Buy me a coffee', 'wp-plugin-starter-template'); ?>
<span class="screen-reader-text"><?php esc_html_e('(opens in a new tab)', 'wp-plugin-starter-template'); ?></span>
</a>
<?php endif; ?>
</div>
</header>
<nav class="wps-nav" aria-label="<?php esc_attr_e('WP Plugin Starter sections', 'wp-plugin-starter-template'); ?>">
<?php foreach ($groups as $group => $group_label) : ?>
<?php
$group_tabs = array_filter($tabs, function ($tab) use ($group) {
return isset($tab['group']) && $tab['group'] === $group;
});
if (!$group_tabs) {
continue;
}
?>
<div class="wps-nav__group" role="group" aria-label="<?php echo esc_attr($group_label); ?>">
<?php foreach ($group_tabs as $slug => $tab) : ?>
<a href="<?php echo esc_url(self::tab_url($slug)); ?>"
class="wps-nav__tab<?php echo $slug === $active ? ' is-active' : ''; ?>"
<?php echo $slug === $active ? 'aria-current="page"' : ''; ?>>
<?php echo esc_html($tab['label']); ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</nav>
<?php // Core moves admin notices after this marker instead of into the header. ?>
<hr class="wp-header-end" />
<?php settings_errors(); ?>
<?php // Not <main>: core's #wpbody already carries role="main". ?>
<div class="wps-main wps-tab-<?php echo esc_attr($active); ?>" id="wps-tab-<?php echo esc_attr($active); ?>">
<?php
if (self::SEARCH === $active) {
WPStarter_Settings_Manager::render_search(self::search_query(), $tabs);
} elseif (isset($tabs[$active]['render']) && is_callable($tabs[$active]['render'])) {
call_user_func($tabs[$active]['render']);
}
?>
</div>
</div>
<?php
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* WP Plugin Starter Read Me tab.
*
* Renders README.md with a small, escaping Markdown subset
* (headings with GitHub-style IDs, lists, tables, bold, italic, inline code,
* http(s) links, links to headings and images from the plugin's folder).
*
* @package WPStarter
* @since 0.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
class WPStarter_Readme_Manager {
/**
* Render the tab.
*/
public static function display_tab_content() {
$readme = wpstarter_get_readme_content();
?>
<article class="wps-card wps-readme">
<?php echo self::parse_markdown($readme['content']); // phpcs:ignore WordPress.Security.EscapeOutput -- built from escaped text. ?>
</article>
<?php
}
/**
* Convert Markdown to HTML. Input is escaped first; only the generated
* tags and validated http(s) links are emitted.
*
* @param string $markdown Markdown.
* @return string HTML.
*/
public static function parse_markdown($markdown) {
$markdown = str_replace('{WPSTARTER_VERSION}', WPSTARTER_VERSION, $markdown);
$lines = preg_split('/\r\n|\r|\n/', $markdown);
$html = '';
$list = '';
$table = ''; // '', 'head' (header row written) or 'body'.
$ids = array();
$close_list = function () use (&$html, &$list, &$table) {
if ($list) {
$html .= '</' . $list . '>';
$list = '';
}
if ($table) {
$html .= ('head' === $table ? '</thead>' : '</tbody>') . '</table></div>';
$table = '';
}
};
foreach ($lines as $line) {
$trim = trim($line);
if ('' === $trim || '#' === $trim) {
$close_list();
continue;
}
// An image on a line of its own, from the plugin's own folder.
if (preg_match('/^!\[([^\]]*)\]\(([^)\s]+)\)$/', $trim, $m)) {
$close_list();
$html .= self::image($m[2], $m[1]);
continue;
}
if (preg_match('/^(#{1,4})\s+(.+)$/', $trim, $m)) {
$close_list();
$level = min(4, strlen($m[1]) + 1); // h1 is reserved for the page title.
$html .= sprintf('<h%1$d id="%2$s">%3$s</h%1$d>', $level, esc_attr(self::anchor($m[2], $ids)), self::inline($m[2]));
continue;
}
// Tables: a header row, a |---| separator, then body rows.
if (strlen($trim) > 1 && '|' === $trim[0] && '|' === substr($trim, -1)) {
if ('head' === $table && preg_match('/^\|[\s:|-]+\|$/', $trim)) {
$html .= '</thead><tbody>';
$table = 'body';
continue;
}
if (!$table) {
$close_list();
$html .= '<div class="wps-readme-table"><table><thead>';
$table = 'head';
}
$tag = 'head' === $table ? 'th' : 'td';
$html .= '<tr>';
foreach (explode('|', substr($trim, 1, -1)) as $cell) {
$html .= '<' . $tag . '>' . self::inline(trim($cell)) . '</' . $tag . '>';
}
$html .= '</tr>';
continue;
}
if (preg_match('/^[-*]\s+(.+)$/', $trim, $m) || preg_match('/^\d+\.\s+(.+)$/', $trim, $n)) {
$type = isset($n[1]) ? 'ol' : 'ul';
$text = isset($n[1]) ? $n[1] : $m[1];
if ($list !== $type) {
$close_list();
$html .= '<' . $type . '>';
$list = $type;
}
$html .= '<li>' . self::inline($text) . '</li>';
unset($n);
continue;
}
$close_list();
$html .= '<p>' . self::inline($trim) . '</p>';
}
$close_list();
return $html;
}
/**
* Heading ID as GitHub makes it, so README links such as
* `[Credits](#credits)` work in the tab too. Repeats get -1, -2...
*
* @param string $text Heading Markdown.
* @param array $ids IDs used so far (updated).
* @return string ID.
*/
private static function anchor($text, array &$ids) {
$text = preg_replace('/\[([^\]]+)\]\([^)]*\)/', '$1', $text);
$text = str_replace(array('`', '*'), '', $text);
$id = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
$id = preg_replace('/[^\p{L}\p{N}\s_-]/u', '', $id);
$id = preg_replace('/\s/u', '-', trim($id));
$base = '' === $id ? 'section' : $id;
$id = $base;
$n = 0;
while (isset($ids[$id])) {
$id = $base . '-' . (++$n);
}
$ids[$id] = true;
return $id;
}
/**
* An image from the plugin's folder, such as the banner at the top of
* README.md. Only relative paths to image files that exist are shown, so
* the tab never loads anything from another site.
*
* @param string $path Path relative to the plugin folder.
* @param string $alt Alternative text.
* @return string HTML, or '' for anything else.
*/
private static function image($path, $alt) {
if (!preg_match('#^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\.(?:svg|png|jpe?g|gif|webp)$#i', $path)) {
return '';
}
$file = WPSTARTER_DIR . $path;
if (!is_file($file)) {
return '';
}
// Width and height stop the page jumping while the image loads.
$size = '';
if ('svg' === strtolower(pathinfo($path, PATHINFO_EXTENSION))) {
$head = (string) file_get_contents($file, false, null, 0, 2048); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file.
if (preg_match('/<svg\b[^>]*?\swidth="(\d+)"[^>]*?\sheight="(\d+)"/', $head, $m)) {
$size = array((int) $m[1], (int) $m[2]);
}
} else {
$size = wp_getimagesize($file);
}
$dims = is_array($size) && !empty($size[0]) && !empty($size[1])
? sprintf(' width="%d" height="%d"', $size[0], $size[1])
: '';
// The file's time in the address, so browsers fetch a changed image
// (such as a new banner after an update) instead of a cached one.
$url = add_query_arg('ver', (string) filemtime($file), WPSTARTER_URL . $path);
return sprintf(
'<p class="wps-readme-image"><img src="%1$s" alt="%2$s"%3$s decoding="async" /></p>',
esc_url($url),
esc_attr($alt),
$dims
);
}
/**
* Inline Markdown on escaped text.
*
* @param string $text Raw text.
* @return string HTML.
*/
private static function inline($text) {
$text = esc_html($text);
$text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
$text = preg_replace('/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/', '<em>$1</em>', $text);
return preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)\)/', function ($m) {
$url = esc_url(html_entity_decode($m[2]), array('http', 'https'));
if ('#' === $m[2][0]) {
return sprintf('<a href="#%1$s">%2$s</a>', esc_attr(substr($m[2], 1)), $m[1]);
}
if ('' === $url) {
return $m[1];
}
return sprintf('<a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>', $url, $m[1]);
}, $text);
}
}
+431
View File
@@ -0,0 +1,431 @@
<?php
/**
* Plugins screen: plugins WP Plugin Starter can replace.
*
* Lists installed plugins that a setting's `replaces` names:
* - active, with every setting that replaces it on: the plugin can go, with a
* Deactivate link (the settings wait until it is deactivated);
* - active, with a replacing setting off: WP Plugin Starter can do this job, with
* a link to the settings (only for people who can change them);
* - installed but inactive, with every replacing setting on: no longer
* needed, with a Delete link (single sites; on multisite a plugin may be
* active on another site).
* While an active plugin does things here that WP Plugin Starter does not (the
* `wpstarter_replaced_plugin_extras` filter), the notice names them
* instead of saying the plugin can go.
*
* Each of those plugins, and inactive ones whose replacing setting is off,
* also gets a note under its own row with the same step.
*
* Shown on the Plugins screen to people who can activate plugins. "Hide"
* hides the plugins listed at the time for that person; a plugin that needs
* a different step later shows again.
*
* @package WPStarter
* @since 0.7.0
*/
if (!defined('ABSPATH')) {
exit;
}
class WPStarter_Replaced_Plugins {
/** admin-post action, also the nonce action. */
const HIDE = 'wpstarter_hide_replaced_plugins';
/** User meta: list of "slug:step" items the person hid. */
const HIDDEN = 'wpstarter_replaced_plugins_hidden';
/** admin-post action that deactivates a replaced plugin and comes back. */
const DEACTIVATE = 'wpstarter_deactivate_replaced';
/** Query argument on the page it comes back to. */
const DONE = 'wpstarter-deactivated';
/**
* Register hooks.
*/
public static function init() {
add_action('load-plugins.php', array(__CLASS__, 'load_screen'));
add_action('admin_post_' . self::HIDE, array(__CLASS__, 'hide'));
add_action('admin_post_' . self::DEACTIVATE, array(__CLASS__, 'deactivate'));
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only shows a message.
if (isset($_GET[self::DONE])) {
add_action('admin_notices', array(__CLASS__, 'deactivated_notice'));
}
}
/**
* Plugins screen: add the notice and the notes on each plugin's row.
*/
public static function load_screen() {
if (!current_user_can('activate_plugins')) {
return;
}
add_action(is_network_admin() ? 'network_admin_notices' : 'admin_notices', array(__CLASS__, 'notice'));
add_action('after_plugin_row', array(__CLASS__, 'row_note'), 10, 1);
add_action('admin_head', array(__CLASS__, 'row_note_style'));
}
/**
* Join a row note to its plugin's row, as core does for update notes.
*/
public static function row_note_style() {
echo '<style>.plugins tr:has(+ tr.wps-replaced-row) th, .plugins tr:has(+ tr.wps-replaced-row) td { box-shadow: none; }</style>' . "\n";
}
/**
* Link that deactivates a replaced plugin on this site and comes back to
* the page it was clicked on.
*
* @param string $file Plugin file.
* @return string Unescaped URL.
*/
public static function deactivate_url($file) {
return wp_nonce_url(add_query_arg(array(
'action' => self::DEACTIVATE,
'plugin' => rawurlencode($file),
'_wp_http_referer' => rawurlencode(remove_query_arg(array(self::DONE, 'deactivate', 'activate'))),
), admin_url('admin-post.php')), self::DEACTIVATE . '_' . $file);
}
/**
* Core's deactivate link on the Plugins screen, keeping the list's
* status, page and search, so it comes back to the same list.
*
* @param string $file Plugin file.
* @return string Unescaped URL.
*/
private static function plugins_deactivate_url($file) {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- the list being shown.
$args = array(
'action' => 'deactivate',
'plugin' => rawurlencode($file),
'plugin_status' => isset($_GET['plugin_status']) ? sanitize_key(wp_unslash($_GET['plugin_status'])) : 'all',
'paged' => isset($_GET['paged']) ? absint($_GET['paged']) : 1,
's' => isset($_GET['s']) ? rawurlencode(sanitize_text_field(wp_unslash($_GET['s']))) : '',
);
// phpcs:enable
return wp_nonce_url(add_query_arg($args, self_admin_url('plugins.php')), 'deactivate-plugin_' . $file);
}
/**
* admin-post: deactivate a replaced plugin on this site, then go back.
*/
public static function deactivate() {
$file = isset($_GET['plugin']) ? sanitize_text_field(wp_unslash($_GET['plugin'])) : '';
check_admin_referer(self::DEACTIVATE . '_' . $file);
if ('' === $file || validate_file($file) || !current_user_can('deactivate_plugin', $file)) {
wp_die(esc_html__('You are not allowed to deactivate this plugin.', 'wp-plugin-starter-template'), '', array('response' => 403));
}
if (!isset(self::replaced()[dirname($file)])) {
wp_die(esc_html__('WP Plugin Starter only deactivates plugins it replaces.', 'wp-plugin-starter-template'), '', array('response' => 400));
}
if (!function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$back = wp_get_referer();
$back = $back ? $back : admin_url('plugins.php');
// Network-wide plugins are deactivated in the network admin.
if (is_plugin_active($file) && !is_plugin_active_for_network($file)) {
deactivate_plugins($file);
update_option('recently_activated', array($file => time()) + (array) get_option('recently_activated'), false);
$back = add_query_arg(self::DONE, 1, $back);
}
wp_safe_redirect($back);
exit;
}
/**
* "Plugin deactivated." on the page the link came back to.
*/
public static function deactivated_notice() {
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__('Plugin deactivated.', 'wp-plugin-starter-template') . '</p></div>';
}
/**
* A note under a replaced plugin's row on the Plugins screen: which
* setting does its job and what to do next.
*
* @param string $file Plugin file.
*/
public static function row_note($file) {
global $wp_list_table;
$states = self::states();
if (!isset($states[$file])) {
return;
}
$item = $states[$file];
$labels = esc_html(implode(', ', array_map(function ($label) {
return '‘' . $label . '’';
}, $item['settings'])));
$setting = '';
if (WPStarter_Settings::can_change()) {
$setting = sprintf(' <a href="%1$s">%2$s</a>', esc_url(WPStarter_Admin_Manager::tab_url(WPStarter_Admin_Manager::SEARCH, array('s' => $item['name']))), esc_html__('Show the setting', 'wp-plugin-starter-template'));
}
$extras = esc_html(implode(', ', $item['extras']));
switch ($item['step']) {
case 'deactivate':
/* translators: %s: WP Plugin Starter setting names */
$text = sprintf(esc_html__('WP Plugin Starter makes this plugin redundant: %s is on and takes over once you deactivate this plugin. Then you can delete it.', 'wp-plugin-starter-template'), $labels);
break;
case 'partial':
/* translators: 1: WP Plugin Starter setting names, 2: the plugin's settings WP Plugin Starter does not have */
$text = sprintf(esc_html__('WP Plugin Starter can do this plugin\'s job: %1$s is on and takes over once you deactivate it, except for these, which WP Plugin Starter does not do: %2$s.', 'wp-plugin-starter-template'), $labels, $extras);
break;
case 'switch_on':
/* translators: %s: WP Plugin Starter setting names */
$text = sprintf(esc_html__('WP Plugin Starter makes this plugin redundant: turn on %s in WP Plugin Starter, then deactivate and delete this plugin.', 'wp-plugin-starter-template'), $labels) . $setting;
break;
case 'switch_on_partial':
/* translators: 1: WP Plugin Starter setting names, 2: the plugin's settings WP Plugin Starter does not have */
$text = sprintf(esc_html__('WP Plugin Starter can do part of this plugin\'s job with %1$s. It does not do these, which this plugin has on: %2$s.', 'wp-plugin-starter-template'), $labels, $extras) . $setting;
break;
case 'switch_on_inactive':
/* translators: %s: WP Plugin Starter setting names */
$text = sprintf(esc_html__('WP Plugin Starter makes this plugin redundant: turn on %s in WP Plugin Starter, then you can delete this plugin.', 'wp-plugin-starter-template'), $labels) . $setting;
break;
default:
/* translators: %s: WP Plugin Starter setting names */
$text = sprintf(esc_html__('No longer needed: %s in WP Plugin Starter does this plugin\'s job. You can delete it.', 'wp-plugin-starter-template'), $labels);
}
$columns = ($wp_list_table instanceof WP_List_Table) ? $wp_list_table->get_column_count() : 4;
$active = in_array($item['step'], array('deactivate', 'partial', 'switch_on', 'switch_on_partial'), true) ? ' active' : ' inactive';
printf(
'<tr class="plugin-update-tr wps-replaced-row%1$s"><td colspan="%2$d" class="plugin-update colspanchange"><div class="notice inline notice-info notice-alt"><p>%3$s</p></div></td></tr>',
esc_attr($active),
(int) $columns,
$text // phpcs:ignore WordPress.Security.EscapeOutput -- built from escaped parts above.
);
}
/**
* Replaced plugins, keyed by folder name, with the settings that replace
* them.
*
* @return array<string,array{name:string,settings:array<string,string>}> slug => name and setting key => label.
*/
private static function replaced() {
$replaced = array();
foreach (WPStarter_Settings::schema() as $key => $field) {
if (empty($field['replaces']) || !is_array($field['replaces']) || !empty($field['parent'])) {
continue;
}
foreach ($field['replaces'] as $slug => $name) {
if (!isset($replaced[$slug])) {
$replaced[$slug] = array('name' => (string) $name, 'settings' => array());
}
$replaced[$slug]['settings'][$key] = isset($field['label']) ? (string) $field['label'] : (string) $key;
}
}
return $replaced;
}
/**
* What to show in the notice: one item per installed replaced plugin that
* needs a step, leaving out what the person hid.
*
* @return array<int,array{slug:string,file:string,name:string,step:string,settings:array<string,string>}>
*/
private static function items() {
$items = array_filter(self::states(), function ($item) {
// Inactive with the setting off: only a note on its row.
return 'switch_on_inactive' !== $item['step']
&& ('switch_on' !== $item['step'] || WPStarter_Settings::can_change());
});
$hidden = (array) get_user_meta(get_current_user_id(), self::HIDDEN, true);
return array_values(array_filter($items, function ($item) use ($hidden) {
return !in_array($item['slug'] . ':' . $item['step'], $hidden, true);
}));
}
/**
* Every installed replaced plugin that needs a step, keyed by plugin file.
*
* Steps: deactivate, partial (active, setting on, it does more),
* switch_on, switch_on_partial, delete (inactive, setting on) and
* switch_on_inactive (inactive, setting off; single sites only).
*
* @return array<string,array{slug:string,file:string,name:string,step:string,settings:array<string,string>,extras:string[]}>
*/
private static function states() {
static $states = null;
if (null !== $states) {
return $states;
}
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$installed = array();
foreach (array_keys(get_plugins()) as $file) {
$slug = dirname($file);
if ('.' !== $slug) {
$installed[$slug] = $file;
}
}
$network = is_multisite() && is_network_admin();
// Active where this screen can deactivate it: network-wide in the
// network admin, on this site otherwise. Stored lists, so plugins
// skipped on this screen still count.
$here = $network
? array_keys((array) get_site_option('active_sitewide_plugins', array()))
: WPStarter_Feature::stored_active_plugins();
$all = WPStarter_Feature::active_plugins();
$items = array();
foreach (self::replaced() as $slug => $plugin) {
if (!isset($installed[$slug])) {
continue;
}
$file = $installed[$slug];
$all_on = true;
foreach (array_keys($plugin['settings']) as $key) {
$all_on = $all_on && (bool) WPStarter_Settings::get($key);
}
if (in_array($file, $here, true)) {
$step = $all_on ? 'deactivate' : 'switch_on';
} elseif (!isset($all[$slug]) && !is_multisite()) {
$step = $all_on ? 'delete' : 'switch_on_inactive';
} else {
continue;
}
$extras = array();
if ('deactivate' === $step || 'switch_on' === $step) {
/**
* What a replaced plugin does on this site that WP Plugin Starter,
* as set up, does not. When there is any, the notice lists it
* instead of saying the plugin can go.
*
* @param string[] $extras Plain names.
* @param string $slug Plugin folder.
*/
$extras = array_values(array_filter(array_map('strval', (array) apply_filters('wpstarter_replaced_plugin_extras', array(), (string) $slug))));
}
if ($extras) {
$step = 'deactivate' === $step ? 'partial' : 'switch_on_partial';
}
$items[$file] = array(
'slug' => (string) $slug,
'file' => $file,
'name' => $plugin['name'],
'step' => $step,
'settings' => $plugin['settings'],
'extras' => $extras,
);
}
$states = $items;
return $states;
}
/**
* The notice.
*/
public static function notice() {
$items = self::items();
if (!$items) {
return;
}
$rows = array();
foreach ($items as $item) {
$rows[] = self::row($item);
}
$keys = array_map(function ($item) {
return $item['slug'] . ':' . $item['step'];
}, $items);
$hide = wp_nonce_url(add_query_arg(array('action' => self::HIDE, 'items' => implode(',', $keys)), admin_url('admin-post.php')), self::HIDE);
?>
<div class="notice notice-info wps-replaced-plugins">
<p><strong><?php esc_html_e('WP Plugin Starter can do the job of these plugins:', 'wp-plugin-starter-template'); ?></strong></p>
<ul class="ul-disc">
<?php foreach ($rows as $row) : ?>
<li><?php echo $row; // phpcs:ignore WordPress.Security.EscapeOutput -- built from escaped parts in row(). ?></li>
<?php endforeach; ?>
</ul>
<p><a href="<?php echo esc_url($hide); ?>"><?php esc_html_e('Hide', 'wp-plugin-starter-template'); ?></a></p>
</div>
<?php
}
/**
* One line of the notice.
*
* @param array $item Item from items().
* @return string HTML.
*/
private static function row(array $item) {
$labels = implode(', ', array_map(function ($label) {
return '‘' . $label . '’';
}, $item['settings']));
$name = '<strong>' . esc_html($item['name']) . '</strong>';
$base = self_admin_url('plugins.php');
if ('deactivate' === $item['step']) {
/* translators: 1: plugin name, 2: WP Plugin Starter setting names */
$text = sprintf(esc_html__('%1$s: %2$s is on and takes over once you deactivate it. Then you can delete it.', 'wp-plugin-starter-template'), $name, esc_html($labels));
if (current_user_can('deactivate_plugin', $item['file'])) {
$url = self::plugins_deactivate_url($item['file']);
/* translators: %s: plugin name */
$text .= sprintf(' <a href="%1$s">%2$s</a>', esc_url($url), esc_html(sprintf(__('Deactivate %s', 'wp-plugin-starter-template'), $item['name'])));
}
return $text;
}
if ('partial' === $item['step'] || 'switch_on_partial' === $item['step']) {
$extras = esc_html(implode(', ', $item['extras']));
if ('partial' === $item['step']) {
/* translators: 1: plugin name, 2: WP Plugin Starter setting names, 3: the plugin's settings WP Plugin Starter does not have */
$text = sprintf(esc_html__('%1$s: %2$s is on and takes over once you deactivate it, except for these, which WP Plugin Starter does not do: %3$s. Deactivate it only if you no longer need them.', 'wp-plugin-starter-template'), $name, esc_html($labels), $extras);
if (current_user_can('deactivate_plugin', $item['file'])) {
$url = self::plugins_deactivate_url($item['file']);
/* translators: %s: plugin name */
$text .= sprintf(' <a href="%1$s">%2$s</a>', esc_url($url), esc_html(sprintf(__('Deactivate %s', 'wp-plugin-starter-template'), $item['name'])));
}
return $text;
}
/* translators: 1: plugin name, 2: WP Plugin Starter setting names, 3: the plugin's settings WP Plugin Starter does not have */
$text = sprintf(esc_html__('%1$s: %2$s can do part of its job. WP Plugin Starter does not do these, which it has on: %3$s.', 'wp-plugin-starter-template'), $name, esc_html($labels), $extras);
$url = WPStarter_Admin_Manager::tab_url(WPStarter_Admin_Manager::SEARCH, array('s' => $item['name']));
return $text . sprintf(' <a href="%1$s">%2$s</a>', esc_url($url), esc_html__('Show the setting', 'wp-plugin-starter-template'));
}
if ('delete' === $item['step']) {
/* translators: 1: plugin name, 2: WP Plugin Starter setting names */
$text = sprintf(esc_html__('%1$s is inactive and no longer needed: %2$s does its job.', 'wp-plugin-starter-template'), $name, esc_html($labels));
if (current_user_can('delete_plugins')) {
$url = wp_nonce_url(add_query_arg(array('action' => 'delete-selected', 'checked[]' => $item['file'], 'plugin_status' => 'all'), $base), 'bulk-plugins');
/* translators: %s: plugin name */
$text .= sprintf(' <a href="%1$s">%2$s</a>', esc_url($url), esc_html(sprintf(__('Delete %s', 'wp-plugin-starter-template'), $item['name'])));
}
return $text;
}
/* translators: 1: plugin name, 2: WP Plugin Starter setting names */
$text = sprintf(esc_html__('%1$s: switch on %2$s, then deactivate and delete it.', 'wp-plugin-starter-template'), $name, esc_html($labels));
$url = WPStarter_Admin_Manager::tab_url(WPStarter_Admin_Manager::SEARCH, array('s' => $item['name']));
return $text . sprintf(' <a href="%1$s">%2$s</a>', esc_url($url), esc_html__('Show the setting', 'wp-plugin-starter-template'));
}
/**
* admin-post: hide the listed items for this person.
*/
public static function hide() {
check_admin_referer(self::HIDE);
if (!current_user_can('activate_plugins')) {
wp_die(esc_html__('You are not allowed to manage plugins on this site.', 'wp-plugin-starter-template'), '', array('response' => 403));
}
$items = isset($_GET['items']) ? explode(',', sanitize_text_field(wp_unslash($_GET['items']))) : array();
$items = array_filter($items, function ($item) {
return (bool) preg_match('/^[A-Za-z0-9._-]+:(deactivate|switch_on|delete|partial|switch_on_partial)$/', $item);
});
$hidden = (array) get_user_meta(get_current_user_id(), self::HIDDEN, true);
update_user_meta(get_current_user_id(), self::HIDDEN, array_values(array_unique(array_filter(array_merge($hidden, $items)))));
$back = wp_get_referer();
wp_safe_redirect($back ? $back : self_admin_url('plugins.php'));
exit;
}
}
+449
View File
@@ -0,0 +1,449 @@
<?php
/**
* WP Plugin Starter settings tabs.
*
* Renders setting cards from WPStarter_Settings::schema(). Each top-level
* setting is a card with a switch; child settings appear in an expandable
* panel. Controls save instantly via AJAX (see admin/js/wpstarter-admin.js).
*
* @package WPStarter
* @since 0.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
class WPStarter_Settings_Manager {
/**
* Render matching settings from every tab, grouped by tab.
*
* Cards are the same as on their own tab, so they can be switched on
* and changed here.
*
* @param string $query Search text.
* @param array $tabs Registered tabs (see WPStarter_Admin_Manager::get_tabs()).
*/
public static function render_search($query, array $tabs) {
$groups = array();
foreach (WPStarter_Settings::search($query) as $key => $field) {
if (isset($tabs[$field['tab']])) {
$groups[$field['tab']][$key] = $field;
}
}
$count = array_sum(array_map('count', $groups));
?>
<div class="wps-section">
<div class="wps-section__intro">
<h2 class="wps-section__title">
<?php
/* translators: %s: search text */
echo esc_html(sprintf(__('Results for “%s”', 'wp-plugin-starter-template'), $query));
?>
</h2>
<p class="wps-section__desc" role="status">
<?php
echo esc_html($count
/* translators: %d: number of matching features */
? sprintf(_n('%d feature found.', '%d features found.', $count, 'wp-plugin-starter-template'), $count)
: __('No features match. Try another word.', 'wp-plugin-starter-template'));
?>
</p>
<?php if ($count) : ?>
<p class="wps-section__hint"><?php esc_html_e('Changes are saved automatically.', 'wp-plugin-starter-template'); ?></p>
<?php endif; ?>
</div>
<?php foreach ($groups as $tab => $fields) : ?>
<h3 class="wps-search__group">
<a href="<?php echo esc_url(WPStarter_Admin_Manager::tab_url($tab)); ?>"><?php echo esc_html($tabs[$tab]['label']); ?></a>
</h3>
<div class="wps-cards">
<?php
foreach ($fields as $key => $field) {
self::render_card($key, $field);
}
?>
</div>
<?php endforeach; ?>
</div>
<?php
}
/**
* Render every top-level setting for a tab.
*
* @param string $tab Tab slug.
* @param string $title Section heading.
* @param string $description Section intro.
*/
public static function render_tab($tab, $title, $description) {
$fields = WPStarter_Settings::fields_for_tab($tab);
?>
<div class="wps-section">
<div class="wps-section__intro">
<h2 class="wps-section__title"><?php echo esc_html($title); ?></h2>
<?php if ($description) : ?>
<p class="wps-section__desc"><?php echo esc_html($description); ?></p>
<?php endif; ?>
<?php if ($fields) : ?>
<p class="wps-section__hint"><?php esc_html_e('Changes are saved automatically.', 'wp-plugin-starter-template'); ?></p>
<?php endif; ?>
</div>
<?php if ($fields) : ?>
<div class="wps-cards">
<?php
foreach ($fields as $key => $field) {
self::render_card($key, $field);
}
?>
</div>
<?php else : ?>
<div class="wps-card wps-empty">
<p><?php esc_html_e('No settings yet.', 'wp-plugin-starter-template'); ?></p>
</div>
<?php endif; ?>
<?php
/**
* Fires after a settings tab's cards, for sections that are not
* settings (such as example data).
*
* @param string $tab Tab slug.
*/
do_action('wpstarter_settings_tab_after', $tab);
?>
</div>
<?php
}
/**
* Render a setting card.
*
* @param string $key Setting key.
* @param array $field Schema entry.
*/
public static function render_card($key, array $field) {
// Hidden options are wiring set by starter data or code, not choices.
$children = array_filter(WPStarter_Settings::children_of($key), function ($child) {
return empty($child['hidden']);
});
$value = WPStarter_Settings::get($key);
$id = 'wps-' . $key;
$panel_id = $id . '-panel';
$is_bool = 'bool' === $field['type'];
?>
<section class="wps-card wps-setting<?php echo ($is_bool && $value) ? ' is-on' : ''; ?><?php echo $children ? ' has-panel' : ''; ?>" data-setting-card="<?php echo esc_attr($key); ?>">
<?php // Clicking the header (outside the switch) opens the options; only the switch changes the value. ?>
<div class="wps-setting__header"<?php echo $children ? ' data-wps-panel-toggle' : ''; ?>>
<?php if ($is_bool) : ?>
<span class="wps-switch">
<input type="checkbox"
role="switch"
class="wps-switch__input"
id="<?php echo esc_attr($id); ?>"
data-wps-setting="<?php echo esc_attr($key); ?>"
aria-labelledby="<?php echo esc_attr($id); ?>-title"
aria-describedby="<?php echo esc_attr($id); ?>-desc"
<?php checked((bool) $value); ?> />
<span class="wps-switch__track" aria-hidden="true"></span>
</span>
<?php endif; ?>
<div class="wps-setting__text">
<div class="wps-setting__title-row">
<span class="wps-setting__title" id="<?php echo esc_attr($id); ?>-title"><?php echo esc_html($field['label']); ?></span>
<span class="wps-status" data-wps-status="<?php echo esc_attr($key); ?>" aria-hidden="true"></span>
</div>
<?php if (!empty($field['description'])) : ?>
<p class="wps-setting__desc" id="<?php echo esc_attr($id); ?>-desc"><?php echo esc_html($field['description']); ?></p>
<?php endif; ?>
<?php self::render_replaces($field); ?>
</div>
<?php if ($children) : ?>
<button type="button"
class="wps-setting__expand button-link"
aria-expanded="false"
aria-controls="<?php echo esc_attr($panel_id); ?>">
<span class="wps-setting__expand-label"><?php esc_html_e('Options', 'wp-plugin-starter-template'); ?></span>
<span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
<span class="screen-reader-text"><?php echo esc_html(sprintf(/* translators: %s: setting name */ __('for %s', 'wp-plugin-starter-template'), $field['label'])); ?></span>
</button>
<?php endif; ?>
</div>
<?php if ($children) : ?>
<div class="wps-setting__panel" id="<?php echo esc_attr($panel_id); ?>" hidden>
<?php
/**
* Fires at the top of a setting's options panel, for status
* such as progress (wrap output in .wps-panel-note).
*
* @param string $key Setting key.
* @param array $field Schema entry.
*/
do_action('wpstarter_setting_panel', $key, $field);
foreach ($children as $child_key => $child) {
self::render_field($child_key, $child);
}
?>
</div>
<?php endif; ?>
</section>
<?php
}
/**
* Note which plugins a setting replaces. While one is active the setting
* waits (WPStarter_Feature::replaced_active()); say so, with a
* deactivate link where the user can deactivate it here.
*
* @param array $field Schema entry.
*/
private static function render_replaces(array $field) {
if (empty($field['replaces']) || !is_array($field['replaces'])) {
return;
}
$active = WPStarter_Feature::active_plugins();
$site = self::active_plugins_by_slug();
$items = array();
$links = array();
$busy = array();
foreach ($field['replaces'] as $slug => $name) {
$items[] = esc_html($name);
if (!isset($active[$slug])) {
continue;
}
$busy[] = (string) $name;
if (isset($site[$slug]) && current_user_can('deactivate_plugin', $site[$slug])) {
// Comes back to this tab, where the setting then takes over.
$url = WPStarter_Replaced_Plugins::deactivate_url($site[$slug]);
/* translators: %s: plugin name */
$links[] = sprintf('<a href="%1$s">%2$s</a>', esc_url($url), esc_html(sprintf(__('Deactivate %s', 'wp-plugin-starter-template'), $name)));
}
}
printf(
'<p class="wps-replaces">%1$s %2$s</p>',
esc_html__('Replaces:', 'wp-plugin-starter-template'),
implode(', ', $items) // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
);
if ($busy) {
printf(
'<p class="wps-replaces wps-replaces__active">%1$s %2$s</p>',
/* translators: %s: plugin names */
esc_html(sprintf(__('%s is still active, so it does this job and this setting waits until it is deactivated.', 'wp-plugin-starter-template'), implode(', ', $busy))),
implode(' · ', $links) // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
);
}
}
/**
* Plugins active on this site (not network-wide) keyed by directory slug.
*
* @return array<string,string> slug => plugin file
*/
private static function active_plugins_by_slug() {
static $active = null;
if (null === $active) {
$active = array();
foreach (WPStarter_Feature::stored_active_plugins() as $file) {
$slug = dirname((string) $file);
if ('.' !== $slug) {
$active[$slug] = (string) $file;
}
}
}
return $active;
}
/**
* Render a child field row.
*
* @param string $key Setting key.
* @param array $field Schema entry.
*/
public static function render_field($key, array $field) {
$value = WPStarter_Settings::get($key);
$id = 'wps-' . $key;
$desc_id = $id . '-desc';
$attrs = sprintf('id="%1$s" data-wps-setting="%2$s" aria-describedby="%3$s"', esc_attr($id), esc_attr($key), esc_attr($desc_id));
$is_multi = 'multi' === $field['type'];
?>
<div class="wps-field">
<?php if ($is_multi) : ?>
<span class="wps-field__label" id="<?php echo esc_attr($id); ?>-label"><?php echo esc_html($field['label']); ?></span>
<?php else : ?>
<label class="wps-field__label" for="<?php echo esc_attr($id); ?>"><?php echo esc_html($field['label']); ?></label>
<?php endif; ?>
<div class="wps-field__control">
<?php
switch ($field['type']) {
case 'select':
printf('<select %s>', $attrs); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
$choices = WPStarter_Settings::options_for($field);
if (!empty($field['open']) && '' !== (string) $value && !array_key_exists((string) $value, $choices)) {
// A saved choice whose plugin is not loaded here stays shown.
$choices[(string) $value] = (string) $value;
}
foreach ($choices as $option_value => $option_label) {
printf(
'<option value="%1$s"%2$s>%3$s</option>',
esc_attr((string) $option_value),
selected((string) $value, (string) $option_value, false),
esc_html($option_label)
);
}
echo '</select>';
break;
case 'multi':
$chosen = array_map('strval', (array) $value);
$choices = WPStarter_Settings::options_for($field);
// With some plugins skipped on this request, a saved
// choice may belong to one of them; keep it too.
if (!empty($field['open']) || WPStarter_Feature::plugins_skipped()) {
// Saved items that are not registered right now stay visible so they can be unticked.
foreach (array_diff($chosen, array_map('strval', array_keys($choices))) as $missing) {
$choices[$missing] = $missing;
}
}
$long = count($choices) > 12;
if ($long) {
// Long lists (such as every active plugin) scroll, with quick choices.
printf(
'<span class="wps-checkboxes__all"><button type="button" class="button-link" data-wps-check-all="%1$s">%2$s</button> · <button type="button" class="button-link" data-wps-check-none="%1$s">%3$s</button></span>',
esc_attr($id),
esc_html__('Select all', 'wp-plugin-starter-template'),
esc_html__('Clear', 'wp-plugin-starter-template')
);
}
// The group carries data-wps-setting; the JS saves every checked value.
printf(
'<fieldset class="wps-checkboxes%3$s" data-wps-multi %1$s aria-labelledby="%2$s">',
$attrs, // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
esc_attr($id . '-label'),
$long ? ' is-long' : ''
);
foreach ($choices as $option_value => $option_label) {
printf(
'<label class="wps-checkbox"><input type="checkbox" value="%1$s"%2$s /> %3$s</label>',
esc_attr((string) $option_value),
checked(in_array((string) $option_value, $chosen, true), true, false),
esc_html($option_label)
);
}
echo '</fieldset>';
break;
case 'url':
printf(
'<input type="text" class="regular-text code" inputmode="url" %1$s value="%2$s" placeholder="%3$s" />',
$attrs, // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
esc_attr((string) $value),
esc_attr(isset($field['placeholder']) ? $field['placeholder'] : '')
);
break;
case 'times':
printf(
'<input type="text" class="regular-text" %1$s value="%2$s" placeholder="%3$s" autocomplete="off" />',
$attrs, // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
esc_attr((string) $value),
esc_attr(isset($field['placeholder']) ? $field['placeholder'] : '')
);
break;
case 'int':
printf(
'<input type="number" class="small-text" %1$s value="%2$s" min="%3$s" max="%4$s" step="1" inputmode="numeric" />',
$attrs, // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
esc_attr((string) $value),
esc_attr(isset($field['min']) ? (string) $field['min'] : ''),
esc_attr(isset($field['max']) ? (string) $field['max'] : '')
);
if (!empty($field['unit'])) {
echo ' <span class="wps-field__unit">' . esc_html($field['unit']) . '</span>';
}
break;
case 'lines':
case 'domains':
printf(
'<textarea class="large-text code" rows="%1$d" %2$s placeholder="%3$s">%4$s</textarea>',
isset($field['rows']) ? (int) $field['rows'] : 3,
$attrs, // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
esc_attr(isset($field['placeholder']) ? $field['placeholder'] : ''),
esc_textarea((string) $value)
);
break;
case 'media':
// The hidden input carries data-wps-setting; the JS sets it
// from the media dialog and saves it.
$image_id = (int) $value;
$preview = $image_id ? wp_get_attachment_image_url($image_id, 'thumbnail') : '';
echo '<div class="wps-media" data-wps-media>';
printf(
'<input type="hidden" data-wps-setting="%1$s" value="%2$s" />',
esc_attr($key),
esc_attr((string) $image_id)
);
if ($preview) {
printf('<img class="wps-media__preview" src="%s" alt="" />', esc_url($preview));
} else {
echo '<img class="wps-media__preview" alt="" hidden />';
}
printf(
'<button type="button" class="button wps-media__choose" id="%1$s" aria-describedby="%2$s">%3$s</button>',
esc_attr($id),
esc_attr($desc_id),
esc_html__('Choose picture', 'wp-plugin-starter-template')
);
printf(
'<button type="button" class="button-link wps-media__remove"%1$s>%2$s</button>',
$image_id ? '' : ' hidden',
esc_html__('Remove', 'wp-plugin-starter-template')
);
echo '</div>';
break;
case 'bool':
printf(
'<span class="wps-switch"><input type="checkbox" role="switch" class="wps-switch__input" %1$s %2$s /><span class="wps-switch__track" aria-hidden="true"></span></span>',
$attrs, // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
checked((bool) $value, true, false)
);
break;
case 'text':
default:
printf(
'<input type="text" class="regular-text" %1$s value="%2$s" placeholder="%3$s" />',
$attrs, // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.
esc_attr((string) $value),
esc_attr(isset($field['placeholder']) ? $field['placeholder'] : '')
);
break;
}
?>
<span class="wps-status" data-wps-status="<?php echo esc_attr($key); ?>" aria-hidden="true"></span>
<?php if (!empty($field['description']) || !empty($field['tokens'])) : ?>
<p class="description" id="<?php echo esc_attr($desc_id); ?>">
<?php echo esc_html(isset($field['description']) ? $field['description'] : ''); ?>
<?php if (!empty($field['tokens'])) : ?>
<span class="wps-tokens">
<?php esc_html_e('Tokens:', 'wp-plugin-starter-template'); ?>
<?php foreach ($field['tokens'] as $token) : ?>
<button type="button" class="wps-token" data-token="<?php echo esc_attr($token); ?>" data-target="<?php echo esc_attr($id); ?>" title="<?php esc_attr_e('Insert token', 'wp-plugin-starter-template'); ?>"><code><?php echo esc_html($token); ?></code></button>
<?php endforeach; ?>
</span>
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</div>
<?php
}
}
+322
View File
@@ -0,0 +1,322 @@
/**
* WP Plugin Starter admin screen.
*
* One delegated controller per concern:
* - Settings: instant save for [data-wps-setting] controls.
* - Panels: expandable setting options.
* - Tokens: insert pattern tokens into text fields.
* - Media fields: choose a Media Library picture with the media dialog.
*
* The plugin's own tabs bring their own script (wpstarter_admin_enqueue),
* which can use window.wpstarterAdmin.api.
*
* Localized data: window.wpstarterAdmin (see WPStarter_Admin_Manager::enqueue_assets()).
*/
(function ($, wp, cfg) {
'use strict';
if (!cfg) {
return;
}
var i18n = cfg.i18n || {};
function speak(message, politeness) {
if (wp && wp.a11y && wp.a11y.speak) {
wp.a11y.speak(message, politeness || 'polite');
}
}
// Some plugins redirect the first admin request after activation to a
// welcome screen, and admin-ajax.php runs admin_init too. The redirect
// happens once, so a reply that is a page instead of JSON is retried
// once. Every action here is safe to repeat.
function post(action, data) {
var deferred = $.Deferred();
var current;
var send = function (retry) {
current = $.ajax({
url: cfg.ajaxUrl,
type: 'POST',
dataType: 'json',
data: $.extend({ action: action, nonce: cfg.nonce }, data)
})
.done(deferred.resolve)
.fail(function (xhr, status, error) {
if (status === 'parsererror' && retry) {
send(false);
} else {
deferred.reject(xhr, status, error);
}
});
};
send(true);
var promise = deferred.promise();
promise.abort = function () {
current.abort();
};
return promise;
}
function errorMessage(xhr, fallback) {
var json = xhr && xhr.responseJSON;
if (json && json.data && json.data.message) {
return json.data.message;
}
return fallback;
}
/* ------------------------------------------------------------------ */
/* Settings */
/* ------------------------------------------------------------------ */
var Settings = {
pending: {},
sequence: 0,
// Saves run one at a time so each reads the settings the last one wrote.
queue: $.Deferred().resolve().promise(),
init: function () {
$(document).on('change', '[data-wps-setting]', function () {
Settings.save($(this));
});
// "Select all" and "Clear" for long checkbox lists: one save for the lot.
$(document).on('click', '[data-wps-check-all], [data-wps-check-none]', function () {
var all = this.hasAttribute('data-wps-check-all');
var $group = $('#' + $(this).attr(all ? 'data-wps-check-all' : 'data-wps-check-none'));
$group.find(':checkbox').prop('checked', all);
Settings.save($group);
});
// Forms that change data elsewhere ask first.
$(document).on('submit', 'form[data-wps-confirm]', function (event) {
if (!window.confirm($(this).attr('data-wps-confirm'))) {
event.preventDefault();
}
});
},
valueOf: function ($input) {
if ($input.is('[data-wps-multi]')) {
return $input.find(':checkbox:checked').map(function () {
return this.value;
}).get();
}
if ($input.is(':checkbox')) {
return $input.is(':checked') ? '1' : '0';
}
return $input.val();
},
status: function (key, state, text, keep) {
var $status = $('[data-wps-status="' + key + '"]');
$status.removeClass('is-saving is-saved is-error').addClass(state ? 'is-' + state : '').text(text || '');
clearTimeout($status.data('timer'));
// "Reload the page" stays until the next change.
if (state === 'saved' && !keep) {
$status.data('timer', setTimeout(function () {
$status.removeClass('is-saved').text('');
}, 2000));
}
},
save: function ($input) {
var key = $input.data('wps-setting');
var value = this.valueOf($input);
var previous = $input.data('wps-saved');
var seq = ++this.sequence;
if (previous === undefined) {
previous = $input.is(':checkbox') ? ($input.is(':checked') ? '0' : '1') : value;
}
this.pending[key] = seq;
this.status(key, 'saving', i18n.saving);
var run = function () {
return post('wpstarter_save_setting', { key: key, value: value })
.done(done)
.fail(failed);
};
var done = function (response) {
if (Settings.pending[key] !== seq) {
return; // A newer save for this key is queued.
}
if (!response || !response.success) {
Settings.fail($input, key, previous, i18n.saveFailed);
return;
}
var saved = response.data.value;
if ($input.is('[data-wps-multi]')) {
var chosen = $.map(saved || [], String);
$input.find(':checkbox').each(function () {
this.checked = $.inArray(this.value, chosen) !== -1;
});
} else if ($input.is(':checkbox')) {
$input.data('wps-saved', saved ? '1' : '0');
} else if (saved !== undefined && String(saved) !== String($input.val())) {
$input.val(saved); // Show the sanitized value.
}
if (!$input.is(':checkbox')) {
$input.data('wps-saved', $input.val());
}
var reload = !!response.data.reload;
var message = reload && response.data.message ? response.data.message : i18n.saved;
Settings.status(key, 'saved', message, reload);
speak(message);
$(document).trigger('wpstarter:setting-saved', [key, saved]);
};
var failed = function (xhr) {
if (Settings.pending[key] === seq) {
Settings.fail($input, key, previous, errorMessage(xhr, i18n.saveFailed));
}
};
// A failed save must not block the ones after it.
this.queue = this.queue.then(run, run);
if ($input.is(':checkbox')) {
$input.closest('[data-setting-card]').filter(function () {
return $(this).data('setting-card') === key;
}).toggleClass('is-on', $input.is(':checked'));
}
},
fail: function ($input, key, previous, message) {
if ($input.is(':checkbox')) {
var on = previous === '1' || previous === true;
$input.prop('checked', on);
$input.closest('[data-setting-card]').toggleClass('is-on', on);
}
this.status(key, 'error', message);
speak(message, 'assertive');
}
};
/* ------------------------------------------------------------------ */
/* Expandable panels and tokens */
/* ------------------------------------------------------------------ */
var Panels = {
toggle: function ($button) {
if (!$button.length) {
return;
}
var expanded = $button.attr('aria-expanded') === 'true';
var panel = document.getElementById($button.attr('aria-controls'));
$button.attr('aria-expanded', expanded ? 'false' : 'true');
if (panel) {
panel.hidden = expanded;
}
$button.closest('.wps-setting').toggleClass('is-expanded', !expanded);
},
init: function () {
$(document).on('click', '.wps-setting__expand', function (event) {
event.stopPropagation();
Panels.toggle($(this));
});
// Mouse convenience: clicking anywhere on the header opens/closes the
// options. The switch (and any other control) keeps its own behaviour;
// keyboard users use the Options button.
$(document).on('click', '[data-wps-panel-toggle]', function (event) {
if ($(event.target).closest('input, button, a, label, select, textarea, .wps-switch').length) {
return;
}
if (window.getSelection && String(window.getSelection()).length) {
return; // Let people select text.
}
Panels.toggle($(this).find('.wps-setting__expand').first());
});
$(document).on('click', '.wps-token', function () {
var input = document.getElementById($(this).data('target'));
var token = String($(this).data('token'));
if (!input) {
return;
}
var start = typeof input.selectionStart === 'number' ? input.selectionStart : input.value.length;
var end = typeof input.selectionEnd === 'number' ? input.selectionEnd : input.value.length;
input.value = input.value.slice(0, start) + token + input.value.slice(end);
input.focus();
input.setSelectionRange(start + token.length, start + token.length);
$(input).trigger('change');
});
}
};
/* ------------------------------------------------------------------ */
/* Media fields: choose a Media Library picture */
/* ------------------------------------------------------------------ */
var MediaField = {
init: function () {
$(document).on('click', '.wps-media__choose', function () {
MediaField.open($(this).closest('[data-wps-media]'));
});
$(document).on('click', '.wps-media__remove', function () {
MediaField.set($(this).closest('[data-wps-media]'), 0, '');
$(this).siblings('.wps-media__choose').trigger('focus');
});
// A saved 0 means the choice was not a picture.
$(document).on('wpstarter:setting-saved', function (event, key, value) {
var $field = $('[data-wps-media]').has('[data-wps-setting="' + key + '"]');
if ($field.length && !parseInt(value, 10)) {
MediaField.preview($field, '');
}
});
},
open: function ($field) {
if (!wp || !wp.media) {
return;
}
var frame = $field.data('wps-frame');
if (!frame) {
frame = wp.media({
title: i18n.chooseImage,
library: { type: 'image' },
multiple: false,
button: { text: i18n.useImage }
});
frame.on('select', function () {
var item = frame.state().get('selection').first();
if (!item) {
return;
}
var data = item.toJSON();
var url = data.sizes && data.sizes.thumbnail ? data.sizes.thumbnail.url : data.url;
MediaField.set($field, data.id, url);
});
$field.data('wps-frame', frame);
}
frame.open();
},
set: function ($field, id, url) {
this.preview($field, url);
$field.find('[data-wps-setting]').val(String(id || 0)).trigger('change');
},
preview: function ($field, url) {
var $img = $field.find('.wps-media__preview');
if (url) {
$img.attr('src', url).prop('hidden', false);
} else {
$img.removeAttr('src').prop('hidden', true);
}
$field.find('.wps-media__remove').prop('hidden', !url);
}
};
// For the plugin's own admin scripts, which load after this one
// (wpstarter_admin_enqueue): the same AJAX, screen reader and error helpers.
cfg.api = { post: post, speak: speak, errorMessage: errorMessage };
$(function () {
Settings.init();
Panels.init();
MediaField.init();
});
})(jQuery, window.wp, window.wpstarterAdmin);
+32
View File
@@ -0,0 +1,32 @@
<?php
/**
* WP Plugin Starter admin loader.
*
* Loads the settings screen, the Read Me tab and the Plugins screen notes
* for replaced plugins, then the plugin's own admin parts
* (WPStarter_Setup::admin()). Included from the main plugin file for
* admin requests only.
*
* @package WPStarter
*/
if (!defined('ABSPATH')) {
exit;
}
$wpstarter_admin_files = array(
'admin/data/readme.php',
'admin/includes/class-settings-manager.php',
'admin/includes/class-readme-manager.php',
'admin/includes/class-admin-manager.php',
'admin/includes/class-replaced-plugins.php',
);
foreach ($wpstarter_admin_files as $wpstarter_file) {
require_once WPSTARTER_DIR . $wpstarter_file;
}
unset($wpstarter_admin_files, $wpstarter_file);
WPStarter_Admin_Manager::init();
WPStarter_Replaced_Plugins::init();
WPStarter_Setup::admin();
+7
View File
@@ -0,0 +1,7 @@
== Changelog ==
Every change to WP Plugin Starter. readme.txt lists the latest version in short.
= 1.0.0 =
* A fresh start, made from the parts every wpallstars plugin needs, taken from plugins in daily use: the settings screen with search, the Read Me tab, features as classes, replaced plugins, the shared GitHub updater, the release and check scripts, CI and the shared rules (STANDARDS.md, DEVELOPMENT.md, RELEASING.md). Earlier versions of this starter are retired; start again from this one.
* New: scripts/rename-plugin.sh gives a copy of the starter its own names, and scripts/sync-core.sh keeps a plugin's core files the same as the starter's.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "wpallstars/wp-plugin-starter-template-for-ai-coding",
"description": "WP Plugin Starter WordPress plugin. Development tools only: the plugin has no Composer dependencies and ships without vendor/.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=7.4"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"szepeviktor/phpstan-wordpress": "^2.0",
"wp-coding-standards/wpcs": "^3.1"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"phpstan/extension-installer": true
},
"platform": {
"php": "7.4.33"
},
"sort-packages": true
},
"scripts": {
"lint": "scripts/lint.sh",
"phpcs": "phpcs",
"phpcbf": "phpcbf",
"phpstan": "phpstan analyse --memory-limit=4G",
"baseline": "phpstan analyse --memory-limit=4G --generate-baseline=phpstan-baseline.neon"
}
}
Generated
+880
View File
@@ -0,0 +1,880 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0914721d7ea1987ea4e5e41b78d877a3",
"packages": [],
"packages-dev": [
{
"name": "dealerdirect/phpcodesniffer-composer-installer",
"version": "v1.2.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/composer-installer.git",
"reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
"reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
"shasum": ""
},
"require": {
"composer-plugin-api": "^2.2",
"php": ">=5.4",
"squizlabs/php_codesniffer": "^3.1.0 || ^4.0"
},
"require-dev": {
"composer/composer": "^2.2",
"ext-json": "*",
"ext-zip": "*",
"php-parallel-lint/php-parallel-lint": "^1.4.0",
"phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev",
"yoast/phpunit-polyfills": "^1.0"
},
"type": "composer-plugin",
"extra": {
"class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
},
"autoload": {
"psr-4": {
"PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Franck Nijhof",
"email": "opensource@frenck.dev",
"homepage": "https://frenck.dev",
"role": "Open source developer"
},
{
"name": "Contributors",
"homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors"
}
],
"description": "PHP_CodeSniffer Standards Composer Installer Plugin",
"keywords": [
"PHPCodeSniffer",
"PHP_CodeSniffer",
"code quality",
"codesniffer",
"composer",
"installer",
"phpcbf",
"phpcs",
"plugin",
"qa",
"quality",
"standard",
"standards",
"style guide",
"stylecheck",
"tests"
],
"support": {
"issues": "https://github.com/PHPCSStandards/composer-installer/issues",
"security": "https://github.com/PHPCSStandards/composer-installer/security/policy",
"source": "https://github.com/PHPCSStandards/composer-installer"
},
"funding": [
{
"url": "https://github.com/PHPCSStandards",
"type": "github"
},
{
"url": "https://github.com/jrfnl",
"type": "github"
},
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
},
{
"url": "https://thanks.dev/u/gh/phpcsstandards",
"type": "thanks_dev"
}
],
"time": "2026-05-06T08:26:05+00:00"
},
{
"name": "php-stubs/wordpress-stubs",
"version": "v7.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-stubs/wordpress-stubs.git",
"reference": "394570301894799e69a4aa117c9c995427e3ba04"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/394570301894799e69a4aa117c9c995427e3ba04",
"reference": "394570301894799e69a4aa117c9c995427e3ba04",
"shasum": ""
},
"conflict": {
"phpdocumentor/reflection-docblock": "5.6.1"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"nikic/php-parser": "^5.5",
"php": "^7.4 || ^8.0",
"php-stubs/generator": "^0.9",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^9.5",
"symfony/polyfill-php80": "*",
"szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.2",
"wp-coding-standards/wpcs": "3.4.1 as 2.3.0"
},
"suggest": {
"paragonie/sodium_compat": "Pure PHP implementation of libsodium",
"symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
"szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan"
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "WordPress function and class declaration stubs for static analysis.",
"homepage": "https://github.com/php-stubs/wordpress-stubs",
"keywords": [
"PHPStan",
"static analysis",
"wordpress"
],
"support": {
"issues": "https://github.com/php-stubs/wordpress-stubs/issues",
"source": "https://github.com/php-stubs/wordpress-stubs/tree/v7.1.0"
},
"time": "2026-08-28T00:32:06+00:00"
},
{
"name": "phpcompatibility/php-compatibility",
"version": "9.3.5",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibility.git",
"reference": "9fb324479acf6f39452e0655d2429cc0d3914243"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243",
"reference": "9fb324479acf6f39452e0655d2429cc0d3914243",
"shasum": ""
},
"require": {
"php": ">=5.3",
"squizlabs/php_codesniffer": "^2.3 || ^3.0.2"
},
"conflict": {
"squizlabs/php_codesniffer": "2.6.2"
},
"require-dev": {
"phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0"
},
"suggest": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.",
"roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
},
"type": "phpcodesniffer-standard",
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Wim Godden",
"homepage": "https://github.com/wimg",
"role": "lead"
},
{
"name": "Juliette Reinders Folmer",
"homepage": "https://github.com/jrfnl",
"role": "lead"
},
{
"name": "Contributors",
"homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors"
}
],
"description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.",
"homepage": "http://techblog.wimgodden.be/tag/codesniffer/",
"keywords": [
"compatibility",
"phpcs",
"standards"
],
"support": {
"issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues",
"source": "https://github.com/PHPCompatibility/PHPCompatibility"
},
"time": "2019-12-27T09:44:58+00:00"
},
{
"name": "phpcompatibility/phpcompatibility-paragonie",
"version": "1.3.4",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git",
"reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
"reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
"shasum": ""
},
"require": {
"phpcompatibility/php-compatibility": "^9.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"paragonie/random_compat": "dev-master",
"paragonie/sodium_compat": "dev-master"
},
"suggest": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
"roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
},
"type": "phpcodesniffer-standard",
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Wim Godden",
"role": "lead"
},
{
"name": "Juliette Reinders Folmer",
"role": "lead"
}
],
"description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.",
"homepage": "http://phpcompatibility.com/",
"keywords": [
"compatibility",
"paragonie",
"phpcs",
"polyfill",
"standards",
"static analysis"
],
"support": {
"issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues",
"security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy",
"source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie"
},
"funding": [
{
"url": "https://github.com/PHPCompatibility",
"type": "github"
},
{
"url": "https://github.com/jrfnl",
"type": "github"
},
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
},
{
"url": "https://thanks.dev/u/gh/phpcompatibility",
"type": "thanks_dev"
}
],
"time": "2025-09-19T17:43:28+00:00"
},
{
"name": "phpcompatibility/phpcompatibility-wp",
"version": "2.1.8",
"source": {
"type": "git",
"url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git",
"reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa",
"reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa",
"shasum": ""
},
"require": {
"phpcompatibility/php-compatibility": "^9.0",
"phpcompatibility/phpcompatibility-paragonie": "^1.0",
"squizlabs/php_codesniffer": "^3.3"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0"
},
"suggest": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
"roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
},
"type": "phpcodesniffer-standard",
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Wim Godden",
"role": "lead"
},
{
"name": "Juliette Reinders Folmer",
"role": "lead"
}
],
"description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.",
"homepage": "http://phpcompatibility.com/",
"keywords": [
"compatibility",
"phpcs",
"standards",
"static analysis",
"wordpress"
],
"support": {
"issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues",
"security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy",
"source": "https://github.com/PHPCompatibility/PHPCompatibilityWP"
},
"funding": [
{
"url": "https://github.com/PHPCompatibility",
"type": "github"
},
{
"url": "https://github.com/jrfnl",
"type": "github"
},
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
},
{
"url": "https://thanks.dev/u/gh/phpcompatibility",
"type": "thanks_dev"
}
],
"time": "2025-10-18T00:05:59+00:00"
},
{
"name": "phpcsstandards/phpcsextra",
"version": "1.5.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
"reference": "39467533fdb742446d68c1d10ac33d625ee0311c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c",
"reference": "39467533fdb742446d68c1d10ac33d625ee0311c",
"shasum": ""
},
"require": {
"php": ">=5.4",
"phpcsstandards/phpcsutils": "^1.2.3",
"squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.4.0",
"phpcsstandards/phpcsdevcs": "^1.2.0",
"phpcsstandards/phpcsdevtools": "^1.2.1",
"phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
},
"type": "phpcodesniffer-standard",
"extra": {
"branch-alias": {
"dev-stable": "1.x-dev",
"dev-develop": "1.x-dev"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Juliette Reinders Folmer",
"homepage": "https://github.com/jrfnl",
"role": "lead"
},
{
"name": "Contributors",
"homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors"
}
],
"description": "A collection of sniffs and standards for use with PHP_CodeSniffer.",
"keywords": [
"PHP_CodeSniffer",
"phpcbf",
"phpcodesniffer-standard",
"phpcs",
"standards",
"static analysis"
],
"support": {
"issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues",
"security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy",
"source": "https://github.com/PHPCSStandards/PHPCSExtra"
},
"funding": [
{
"url": "https://github.com/PHPCSStandards",
"type": "github"
},
{
"url": "https://github.com/jrfnl",
"type": "github"
},
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
},
{
"url": "https://thanks.dev/u/gh/phpcsstandards",
"type": "thanks_dev"
}
],
"time": "2026-07-27T11:13:17+00:00"
},
{
"name": "phpcsstandards/phpcsutils",
"version": "1.2.3",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
"reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f",
"reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f",
"shasum": ""
},
"require": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0",
"php": ">=5.4",
"squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
},
"require-dev": {
"ext-filter": "*",
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.4.0",
"phpcsstandards/phpcsdevcs": "^1.2.0",
"yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0"
},
"type": "phpcodesniffer-standard",
"extra": {
"branch-alias": {
"dev-stable": "1.x-dev",
"dev-develop": "1.x-dev"
}
},
"autoload": {
"classmap": [
"PHPCSUtils/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Juliette Reinders Folmer",
"homepage": "https://github.com/jrfnl",
"role": "lead"
},
{
"name": "Contributors",
"homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors"
}
],
"description": "A suite of utility functions for use with PHP_CodeSniffer",
"homepage": "https://phpcsutils.com/",
"keywords": [
"PHP_CodeSniffer",
"phpcbf",
"phpcodesniffer-standard",
"phpcs",
"phpcs3",
"phpcs4",
"standards",
"static analysis",
"tokens",
"utility"
],
"support": {
"docs": "https://phpcsutils.com/",
"issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues",
"security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy",
"source": "https://github.com/PHPCSStandards/PHPCSUtils"
},
"funding": [
{
"url": "https://github.com/PHPCSStandards",
"type": "github"
},
{
"url": "https://github.com/jrfnl",
"type": "github"
},
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
},
{
"url": "https://thanks.dev/u/gh/phpcsstandards",
"type": "thanks_dev"
}
],
"time": "2026-07-27T10:28:41+00:00"
},
{
"name": "phpstan/extension-installer",
"version": "1.4.3",
"source": {
"type": "git",
"url": "https://github.com/phpstan/extension-installer.git",
"reference": "85e90b3942d06b2326fba0403ec24fe912372936"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936",
"reference": "85e90b3942d06b2326fba0403ec24fe912372936",
"shasum": ""
},
"require": {
"composer-plugin-api": "^2.0",
"php": "^7.2 || ^8.0",
"phpstan/phpstan": "^1.9.0 || ^2.0"
},
"require-dev": {
"composer/composer": "^2.0",
"php-parallel-lint/php-parallel-lint": "^1.2.0",
"phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0"
},
"type": "composer-plugin",
"extra": {
"class": "PHPStan\\ExtensionInstaller\\Plugin"
},
"autoload": {
"psr-4": {
"PHPStan\\ExtensionInstaller\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Composer plugin for automatic installation of PHPStan extensions",
"keywords": [
"dev",
"static analysis"
],
"support": {
"issues": "https://github.com/phpstan/extension-installer/issues",
"source": "https://github.com/phpstan/extension-installer/tree/1.4.3"
},
"time": "2024-09-04T20:21:43+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.2.16",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ondřej Mirtes"
},
{
"name": "Markus Staab"
},
{
"name": "Vincent Langlet"
}
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-09-25T09:31:51+00:00"
},
{
"name": "squizlabs/php_codesniffer",
"version": "3.13.6",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
"reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
"reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91",
"shasum": ""
},
"require": {
"ext-simplexml": "*",
"ext-tokenizer": "*",
"ext-xmlwriter": "*",
"php": ">=5.4.0"
},
"require-dev": {
"phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
},
"bin": [
"bin/phpcbf",
"bin/phpcs"
],
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Greg Sherwood",
"role": "Former lead"
},
{
"name": "Juliette Reinders Folmer",
"role": "Current lead"
},
{
"name": "Contributors",
"homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
}
],
"description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
"homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
"keywords": [
"phpcs",
"standards",
"static analysis"
],
"support": {
"issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
"security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
"source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
"wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
},
"funding": [
{
"url": "https://github.com/PHPCSStandards",
"type": "github"
},
{
"url": "https://github.com/jrfnl",
"type": "github"
},
{
"url": "https://opencollective.com/php_codesniffer",
"type": "open_collective"
},
{
"url": "https://thanks.dev/u/gh/phpcsstandards",
"type": "thanks_dev"
}
],
"time": "2026-08-06T00:17:32+00:00"
},
{
"name": "szepeviktor/phpstan-wordpress",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/szepeviktor/phpstan-wordpress.git",
"reference": "84577e2ea1e4c2a95537fe5ea2ac8e18f4ceab1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/84577e2ea1e4c2a95537fe5ea2ac8e18f4ceab1a",
"reference": "84577e2ea1e4c2a95537fe5ea2ac8e18f4ceab1a",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"php-stubs/wordpress-stubs": ">=6.6.2",
"phpstan/phpstan": "^2.0"
},
"require-dev": {
"composer/composer": "^2.1.14",
"composer/semver": "^3.4",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.1",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.0",
"szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.0",
"wp-coding-standards/wpcs": "3.1.0 as 2.3.0"
},
"suggest": {
"swissspidy/phpstan-no-private": "Detect usage of internal core functions, classes and methods"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
},
"autoload": {
"psr-4": {
"SzepeViktor\\PHPStan\\WordPress\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "WordPress extensions for PHPStan",
"keywords": [
"PHPStan",
"code analyse",
"code analysis",
"static analysis",
"wordpress"
],
"support": {
"issues": "https://github.com/szepeviktor/phpstan-wordpress/issues",
"source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.4"
},
"time": "2026-05-22T16:22:09+00:00"
},
{
"name": "wp-coding-standards/wpcs",
"version": "3.4.1",
"source": {
"type": "git",
"url": "https://github.com/WordPress/WordPress-Coding-Standards.git",
"reference": "ec2ff942335f33683a5957a85d138753876a05cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf",
"reference": "ec2ff942335f33683a5957a85d138753876a05cf",
"shasum": ""
},
"require": {
"ext-filter": "*",
"ext-libxml": "*",
"ext-tokenizer": "*",
"ext-xmlreader": "*",
"php": ">=7.2",
"phpcsstandards/phpcsextra": "^1.5.1",
"phpcsstandards/phpcsutils": "^1.2.3",
"squizlabs/php_codesniffer": "^3.13.5"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.4.0",
"phpcompatibility/php-compatibility": "^10.0.0@dev",
"phpcsstandards/phpcsdevtools": "^1.2.0",
"phpunit/phpunit": "^8.0 || ^9.0"
},
"suggest": {
"ext-iconv": "For improved results",
"ext-mbstring": "For improved results"
},
"type": "phpcodesniffer-standard",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Contributors",
"homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors"
}
],
"description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions",
"keywords": [
"phpcs",
"standards",
"static analysis",
"wordpress"
],
"support": {
"issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues",
"source": "https://github.com/WordPress/WordPress-Coding-Standards",
"wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki"
},
"funding": [
{
"url": "https://opencollective.com/php_codesniffer",
"type": "custom"
}
],
"time": "2026-07-27T11:53:23+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=7.4"
},
"platform-dev": {},
"platform-overrides": {
"php": "7.4.33"
},
"plugin-api-version": "2.6.0"
}
+273
View File
@@ -0,0 +1,273 @@
<?php
/**
* Base class for WP Plugin Starter features.
*
* A feature is a self-contained class that:
* - declares its settings in settings() (the on/off switch first, keyed by KEY,
* then any options with 'parent' => KEY);
* - registers its hooks in boot(), which runs on `init` (priority 0, before
* widgets_init) for every registered feature. Disabled features should
* return early so they cost nothing;
* - optionally imports settings from the plugin it replaces in migrate().
*
* While a plugin that a setting replaces is active, the setting waits:
* enabled() is false and that plugin keeps doing the job, so the two never
* run side by side (two admin bar menus, analytics loaded twice). The
* settings card says so, with a deactivate link.
*
* Register extra features with the `wpstarter_features` filter.
*
* @package WPStarter
* @since 0.3.0
*/
if (!defined('ABSPATH')) {
exit;
}
abstract class WPStarter_Feature {
/** Setting key of the feature's on/off switch. */
const KEY = '';
/**
* Settings schema entries for this feature.
*
* @return array<string,array>
*/
public static function settings() {
return array();
}
/**
* Whether the feature is switched on and not waiting for a plugin it
* replaces to be deactivated.
*
* @return bool
*/
public static function enabled() {
return static::switched_on() && !self::replaced_active(static::KEY);
}
/**
* Whether the feature's switch is on, even if it is waiting.
*
* @return bool
*/
public static function switched_on() {
return '' !== static::KEY && (bool) WPStarter_Settings::get(static::KEY);
}
/**
* Names of the active plugins that a setting replaces.
*
* @param string $key Setting key.
* @return array<string,string> slug => name
*/
public static function replaced_active($key) {
$schema = WPStarter_Settings::schema();
if (empty($schema[$key]['replaces']) || !is_array($schema[$key]['replaces'])) {
return array();
}
return array_intersect_key($schema[$key]['replaces'], self::active_plugins());
}
/**
* Plugins active on this site or network-wide, keyed by folder name.
*
* @return array<string,string> slug => plugin file
*/
public static function active_plugins() {
static $active = null;
if (null === $active) {
$files = self::stored_active_plugins();
if (is_multisite()) {
$files = array_merge($files, array_keys((array) get_site_option('active_sitewide_plugins', array())));
}
$active = array();
foreach ($files as $file) {
$slug = dirname((string) $file);
if ('.' !== $slug) {
$active[$slug] = (string) $file;
}
}
}
return $active;
}
/**
* Plugin files active on this site (not network-wide), as stored: a
* plugin skipped on this request still counts.
*
* @return string[]
*/
public static function stored_active_plugins() {
$files = get_option('active_plugins', array());
$files = is_array($files) ? array_values(array_filter($files, 'is_string')) : array();
/**
* Filter the active plugin files as stored, for code that skips
* plugins on some requests (WP Plugin Starter's "Load plugins only
* where needed" filters the option itself).
*
* @param string[] $files Plugin files, such as "akismet/akismet.php".
*/
$files = apply_filters('wpstarter_stored_active_plugins', $files);
return is_array($files) ? array_values(array_filter($files, 'is_string')) : array();
}
/**
* Whether some active plugins are skipped on this request, so things
* they register (post types, widgets) may be missing.
*
* @return bool
*/
public static function plugins_skipped() {
/**
* Filter whether some active plugins are skipped on this request.
*
* @param bool $skipped Whether they are.
*/
return (bool) apply_filters('wpstarter_plugins_skipped', false);
}
/**
* Import settings once, when the stored settings version is older than
* WPStarter_Settings::DB_VERSION. Only fill keys that are not stored yet.
*
* @param array $options Stored settings (raw, without defaults).
* @param int $from_version Stored settings version before this upgrade.
* @return array
*/
public static function migrate(array $options, $from_version) {
return $options;
}
/**
* Set a setting during migrate() unless it is already stored.
* (Named import_setting() so features may keep their own import() methods.)
*
* @param array $options Stored settings.
* @param string $key Setting key.
* @param mixed $value Raw value; null skips.
* @return array
*/
protected static function import_setting(array $options, $key, $value) {
$schema = WPStarter_Settings::schema();
if (null !== $value && !array_key_exists($key, $options) && isset($schema[$key])) {
$options[$key] = WPStarter_Settings::sanitize_value($value, $schema[$key]);
}
return $options;
}
/**
* Role options for multi settings.
*
* @return array<string,string>
*/
public static function role_options() {
$roles = array();
foreach (wp_roles()->get_names() as $role => $name) {
$roles[$role] = translate_user_role($name);
}
return $roles;
}
/**
* Role options for settings that restrict people, without roles that can
* manage options (such as Administrator): current_user_in_roles() never
* matches them, so offering them would only mislead.
*
* @return array<string,string>
*/
public static function restrictable_role_options() {
$roles = array();
foreach (wp_roles()->roles as $role => $data) {
if (empty($data['capabilities']['manage_options'])) {
$roles[$role] = translate_user_role($data['name']);
}
}
return $roles;
}
/**
* Whether the current user has one of the roles. Users who can manage
* options are never matched, so admins cannot lock themselves out.
*
* @param mixed $roles Role slugs.
* @return bool
*/
public static function current_user_in_roles($roles) {
$user = wp_get_current_user();
if (!$user->exists() || user_can($user, 'manage_options')) {
return false;
}
return (bool) array_intersect((array) $user->roles, (array) $roles);
}
/**
* Whether a batch that began at $start has time for one more item.
*
* The batch keeps to its own budget, and also to PHP's time limit for
* the whole request: WP-Cron runs every due batch in one request, so two
* 20-second batches would pass a 30-second limit. Where the host allows
* it, the limit is restarted for the next item instead.
*
* @param float $start microtime(true) when the batch began.
* @param int $budget Seconds the batch may run.
* @return bool
*/
public static function more_time($start, $budget) {
$now = microtime(true);
if ($now - $start >= $budget) {
return false;
}
$limit = (int) ini_get('max_execution_time');
if ($limit <= 0) {
return true;
}
// set_time_limit() restarts the count; hosts may disable it.
if (function_exists('set_time_limit') && @set_time_limit(max($limit, 60))) { // phpcs:ignore WordPress.PHP.NoSilencedErrors, Squiz.PHP.DiscouragedFunctions -- may be disabled by the host.
return true;
}
$begun = isset($_SERVER['REQUEST_TIME_FLOAT']) ? (float) $_SERVER['REQUEST_TIME_FLOAT'] : $start;
$used = $now - $begun;
// On Linux the limit counts CPU time of every thread, and image
// libraries use several, so CPU time can run ahead of the clock.
// getrusage() counts the whole process, which under PHP-FPM serves
// many requests (thousands of seconds on a test site), so only CPU
// time since the first call in this request counts, plus the clock
// time before it.
$cpu = self::cpu_seconds();
if (null !== $cpu) {
if (null === self::$cpu_base) {
self::$cpu_base = array($cpu, $now - $begun);
}
$used = max($used, $cpu - self::$cpu_base[0] + self::$cpu_base[1]);
}
return $used < $limit - min(10, $limit / 3);
}
/** CPU seconds and request seconds at the first more_time() check. @var array|null */
private static $cpu_base = null;
/**
* CPU seconds this process has used, or null when unknown.
*
* @return float|null
*/
private static function cpu_seconds() {
if (!function_exists('getrusage')) {
return null;
}
$usage = getrusage();
if (!is_array($usage) || !isset($usage['ru_utime.tv_sec'], $usage['ru_stime.tv_sec'], $usage['ru_utime.tv_usec'], $usage['ru_stime.tv_usec'])) {
return null;
}
return $usage['ru_utime.tv_sec'] + $usage['ru_stime.tv_sec'] + ($usage['ru_utime.tv_usec'] + $usage['ru_stime.tv_usec']) / 1e6;
}
/**
* Register hooks.
*/
abstract public static function boot();
}
+599
View File
@@ -0,0 +1,599 @@
<?php
/**
* WP Plugin Starter settings store.
*
* Every setting lives in a single `wpstarter_options` array and is described
* by a schema entry (type, default, UI metadata) declared by its feature.
* Features read values with WPStarter_Settings::get(); the admin UI renders
* cards from the same schema; the AJAX endpoint and the Settings API sanitize
* through it. Add settings with the `wpstarter_settings_schema` filter or by
* registering a feature (see WPStarter_Feature).
*
* @package WPStarter
* @since 0.3.0
*/
if (!defined('ABSPATH')) {
exit;
}
class WPStarter_Settings {
/** Option that stores every setting. */
const OPTION = 'wpstarter_options';
/** Settings API group. */
const GROUP = 'wpstarter_settings';
/** Nonce action shared by admin AJAX requests. */
const NONCE = 'wpstarter_admin';
/** Stored schema version, used for one-off migrations. */
const DB_VERSION_OPTION = 'wpstarter_db_version';
/** Current schema version; its history is in WPStarter_Setup. */
const DB_VERSION = WPStarter_Setup::DB_VERSION;
/**
* Request-level cache of the resolved schema.
*
* @var array|null
*/
private static $schema = null;
/**
* Request-level cache of defaults.
*
* @var array|null
*/
private static $defaults = null;
/**
* Register hooks.
*/
public static function init() {
// Priority 0, before WPStarter::boot_features() so features read
// migrated values, and before widgets_init (init:1).
add_action('init', array(__CLASS__, 'maybe_migrate'), 0);
add_action('admin_init', array(__CLASS__, 'register_setting'));
add_action('wp_ajax_wpstarter_save_setting', array(__CLASS__, 'ajax_save'));
add_filter('option_page_capability_' . self::GROUP, array(__CLASS__, 'capability'));
}
/**
* Whether the current user may change WP Plugin Starter's settings.
*
* @return bool
*/
public static function can_change() {
/**
* Filter whether the current user may change WP Plugin Starter's settings
* (on top of the manage_options check).
*
* @param bool $can Whether they may.
*/
return current_user_can('manage_options') && (bool) apply_filters('wpstarter_can_change_settings', true);
}
/**
* Capability options.php checks before saving the settings.
*
* @param string $capability Capability.
* @return string
*/
public static function capability($capability) {
return self::can_change() ? $capability : 'do_not_allow';
}
/**
* Setting definitions, collected from the registered features.
*
* Keys:
* - type: bool | int | text | domains | select | multi | times
* - default: default value
* - tab: admin tab slug (top-level settings only)
* - parent: parent setting key (renders inside the parent's panel)
* - options: value => label array, or a callable returning one (select, multi)
* - open: multi only; also keep key-like values that are not (yet)
* in options, e.g. post types or widgets registered later
* - replaces: top-level only; plugin slug => name this setting replaces
* - reload: true when the change shows only after a page load; the
* saved message then asks to reload the page
* - label, description, placeholder, min, max, unit, tokens, rows: UI metadata
*
* Types also include `url` (one URL or site path), `lines` (one entry
* per line, sanitized as plain text) and `media` (a Media Library
* picture's ID, chosen with the media dialog).
*
* @return array<string,array>
*/
public static function schema() {
if (null !== self::$schema) {
return self::$schema;
}
$schema = array();
foreach (WPStarter::features() as $class) {
$schema += (array) $class::settings();
}
/**
* Filter the settings schema to add or adjust settings.
*
* @param array $schema Setting definitions keyed by setting key.
*/
$schema = (array) apply_filters('wpstarter_settings_schema', $schema);
foreach ($schema as $key => $field) {
if (isset($field['tab']) && is_string($field['tab'])) {
$schema[$key]['tab'] = self::resolve_tab($field['tab']);
}
}
self::$schema = $schema;
return self::$schema;
}
/**
* Resolve a select/multi field's options.
*
* Options may be a callable so they can depend on data registered at
* `init` (post types, roles) without building it for every request.
*
* @param array $field Schema entry.
* @return array<string,string> value => label
*/
public static function options_for(array $field) {
if (empty($field['options'])) {
return array();
}
$options = is_callable($field['options']) ? call_user_func($field['options']) : $field['options'];
return is_array($options) ? $options : array();
}
/**
* Default values for every setting.
*
* @return array
*/
public static function defaults() {
if (null === self::$defaults) {
self::$defaults = array();
foreach (self::schema() as $key => $field) {
self::$defaults[$key] = isset($field['default']) ? $field['default'] : null;
}
}
return self::$defaults;
}
/**
* All settings merged over defaults.
*
* @return array
*/
public static function all() {
$stored = get_option(self::OPTION, array());
if (!is_array($stored)) {
$stored = array();
}
return array_merge(self::defaults(), array_intersect_key($stored, self::schema()));
}
/**
* Read one setting.
*
* @param string $key Setting key.
* @return mixed Value, or null for unknown keys.
*/
public static function get($key) {
$all = self::all();
return array_key_exists($key, $all) ? $all[$key] : null;
}
/**
* Update one setting after sanitizing it.
*
* @param string $key Setting key.
* @param mixed $value Raw value.
* @return mixed|WP_Error Sanitized value, or error for unknown keys.
*/
public static function set($key, $value) {
$schema = self::schema();
if (!isset($schema[$key])) {
return new WP_Error('wpstarter_unknown_setting', __('Unknown setting.', 'wp-plugin-starter-template'));
}
// Read what is stored now, not the copy loaded when this request
// began, so a save made meanwhile is not overwritten.
self::flush_cache();
$options = self::all();
$options[$key] = self::sanitize_value($value, $schema[$key]);
update_option(self::OPTION, $options);
if (!self::stored($key, $options[$key])) {
return new WP_Error('wpstarter_not_saved', __('The setting could not be saved. Please try again.', 'wp-plugin-starter-template'));
}
return $options[$key];
}
/**
* Drop cached copies of the settings so the next read hits the database.
*/
private static function flush_cache() {
wp_cache_delete(self::OPTION, 'options');
wp_cache_delete('alloptions', 'options');
}
/**
* Whether the database holds a value for a setting (update_option()
* returns false both for "unchanged" and for a failed write).
*
* @param string $key Setting key.
* @param mixed $value Expected value.
* @return bool
*/
private static function stored($key, $value) {
self::flush_cache();
$stored = get_option(self::OPTION);
return is_array($stored) && array_key_exists($key, $stored) && $stored[$key] === $value;
}
/**
* Top-level settings for a tab.
*
* @param string $tab Tab slug.
* @return array
*/
public static function fields_for_tab($tab) {
return array_filter(self::schema(), function ($field) use ($tab) {
return empty($field['parent']) && isset($field['tab']) && $field['tab'] === $tab;
});
}
/**
* Map a renamed tab slug to its current slug
* (WPStarter_Setup::RENAMED_TABS).
*
* @param string $tab Tab slug.
* @return string
*/
public static function resolve_tab($tab) {
$renamed = WPStarter_Setup::RENAMED_TABS;
return array_key_exists($tab, $renamed) ? $renamed[$tab] : $tab;
}
/**
* Top-level settings that match a search, in schema order.
*
* Matches the label, description, replaced plugin names and the labels
* of child options, ignoring case.
*
* @param string $query Search text.
* @return array
*/
public static function search($query) {
$query = trim((string) $query);
if ('' === $query) {
return array();
}
$matches = array();
foreach (self::schema() as $key => $field) {
if (!empty($field['parent']) || empty($field['tab'])) {
continue;
}
$haystack = array(
$key,
isset($field['label']) ? $field['label'] : '',
isset($field['description']) ? $field['description'] : '',
);
if (!empty($field['replaces']) && is_array($field['replaces'])) {
$haystack = array_merge($haystack, array_keys($field['replaces']), array_values($field['replaces']));
}
foreach (self::children_of($key) as $child) {
$haystack[] = isset($child['label']) && empty($child['hidden']) ? (string) $child['label'] : '';
}
$text = implode(' ', array_map('strval', $haystack));
$found = function_exists('mb_stripos') ? mb_stripos($text, $query) : stripos($text, $query);
if (false !== $found) {
$matches[$key] = $field;
}
}
return $matches;
}
/**
* Child settings rendered in a parent's panel.
*
* @param string $parent Parent setting key.
* @return array
*/
public static function children_of($parent) {
return array_filter(self::schema(), function ($field) use ($parent) {
return isset($field['parent']) && $field['parent'] === $parent;
});
}
/**
* Sanitize a value according to its schema entry.
*
* Must be idempotent: update_option() re-runs sanitize_all() on the
* already-sanitized array.
*
* @param mixed $value Raw value.
* @param array $field Schema entry.
* @return mixed
*/
public static function sanitize_value($value, array $field) {
$type = isset($field['type']) ? $field['type'] : 'text';
$default = isset($field['default']) ? $field['default'] : null;
switch ($type) {
case 'bool':
return rest_sanitize_boolean($value);
case 'int':
$value = is_numeric($value) ? (int) $value : (int) $default;
if (isset($field['min'])) {
$value = max((int) $field['min'], $value);
}
if (isset($field['max'])) {
$value = min((int) $field['max'], $value);
}
return $value;
case 'domains':
return implode("\n", self::parse_domains($value));
case 'media':
// A Media Library picture's ID, or 0.
$value = is_numeric($value) ? absint($value) : 0;
return $value && wp_attachment_is_image($value) ? $value : 0;
case 'select':
$value = is_scalar($value) ? (string) $value : '';
if (array_key_exists($value, self::options_for($field))) {
return $value;
}
// Open selects (another plugin's board or course) keep an
// identifier whose plugin is not loaded on this request.
return !empty($field['open']) && strlen($value) <= 200 && preg_match('/^[A-Za-z0-9_-]+$/', $value) ? $value : $default;
case 'multi':
$values = is_array($value) ? $value : preg_split('/\s*,\s*/', (string) $value, -1, PREG_SPLIT_NO_EMPTY);
$values = array_map('strval', array_filter((array) $values, 'is_scalar'));
// Keep the options' order so stored values are stable.
$known = array_values(array_intersect(array_map('strval', array_keys(self::options_for($field))), $values));
if (empty($field['open'])) {
return $known;
}
// Open lists also keep identifiers that are not registered right now
// (class names may contain namespace separators).
$extra = array_filter(array_diff($values, $known), function ($item) {
return strlen($item) <= 200 && (bool) preg_match('/^[A-Za-z0-9_\\\\-]+$/', $item);
});
return array_values(array_unique(array_merge($known, $extra)));
case 'times':
return implode(', ', self::parse_times($value));
case 'url':
$value = trim((string) $value);
if ('' === $value) {
return '';
}
if ('/' === $value[0] && '/' !== substr($value, 1, 1)) {
// Site path: keep it relative so it survives domain changes.
return '/' . ltrim(preg_replace('/\s+/', '', sanitize_text_field($value)), '/');
}
return esc_url_raw($value, array('http', 'https'));
case 'lines':
$lines = preg_split('/[\r\n]+/', (string) $value);
// Not sanitize_text_field(): it strips %xx, which URL paths need.
$lines = array_filter(array_map(function ($line) {
return trim(preg_replace('/[\x00-\x1F\x7F]+/', '', wp_strip_all_tags($line)));
}, $lines), function ($line) {
return '' !== $line;
});
return implode("\n", array_values(array_unique($lines)));
case 'text':
default:
$value = (string) $value;
if (empty($field['tokens'])) {
return sanitize_text_field($value);
}
// sanitize_text_field() drops "%" plus two hex digits, which
// breaks tokens such as %date% and %day%; set them aside.
$tokens = array();
foreach (array_values((array) $field['tokens']) as $i => $token) {
$tokens["\u{E000}{$i}\u{E001}"] = (string) $token;
}
$value = str_replace(array_values($tokens), array_keys($tokens), $value);
return str_replace(array_keys($tokens), array_values($tokens), sanitize_text_field($value));
}
}
/**
* Normalise a newline/comma separated domain list to bare lowercase hosts.
*
* @param mixed $value Raw list.
* @return string[]
*/
public static function parse_domains($value) {
$lines = preg_split('/[\r\n,]+/', (string) $value);
$domains = array();
foreach ($lines as $line) {
$line = trim($line);
if ('' === $line) {
continue;
}
$host = wp_parse_url(false === strpos($line, '://') ? 'http://' . $line : $line, PHP_URL_HOST);
$host = $host ? strtolower(preg_replace('/^www\./i', '', $host)) : '';
if ('' !== $host && preg_match('/^[a-z0-9.-]+$/', $host)) {
$domains[] = $host;
}
}
return array_values(array_unique($domains));
}
/**
* Whether a host is one of the domains or a subdomain of one.
*
* @param string $host Host name.
* @param string[] $domains Normalised domains from parse_domains().
* @return bool
*/
public static function host_matches($host, array $domains) {
$host = strtolower(preg_replace('/^www\./i', '', (string) $host));
foreach ($domains as $domain) {
if ($host === $domain || substr($host, -strlen('.' . $domain)) === '.' . $domain) {
return true;
}
}
return false;
}
/**
* Normalise a list of times of day to sorted, unique 24-hour "HH:MM".
*
* Accepts "9", "9:30", "09:30", "9.30", "9pm", "9:30 am" separated by
* commas, spaces or new lines.
*
* @param mixed $value Raw list.
* @return string[]
*/
public static function parse_times($value) {
preg_match_all('/(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)?/i', (string) $value, $matches, PREG_SET_ORDER);
$times = array();
foreach ($matches as $match) {
$hour = (int) $match[1];
$minute = isset($match[2]) && '' !== $match[2] ? (int) $match[2] : 0;
$suffix = isset($match[3]) ? strtolower($match[3]) : '';
if ('pm' === $suffix && $hour < 12) {
$hour += 12;
} elseif ('am' === $suffix && 12 === $hour) {
$hour = 0;
}
if ($hour > 23 || $minute > 59) {
continue;
}
$times[] = sprintf('%02d:%02d', $hour, $minute);
}
$times = array_values(array_unique($times));
sort($times);
return $times;
}
/**
* Sanitize the whole array when saved through options.php or update_option().
*
* @param mixed $input Raw option value.
* @return array
*/
public static function sanitize_all($input) {
$input = is_array($input) ? $input : array();
$schema = self::schema();
$clean = self::all();
foreach ($input as $key => $value) {
if (isset($schema[$key])) {
$clean[$key] = self::sanitize_value($value, $schema[$key]);
}
}
return $clean;
}
/**
* Register the option with the Settings API.
*/
public static function register_setting() {
register_setting(self::GROUP, self::OPTION, array(
'type' => 'object',
'sanitize_callback' => array(__CLASS__, 'sanitize_all'),
'default' => self::defaults(),
'show_in_rest' => false,
));
}
/**
* AJAX: save a single setting (used by instant-save controls).
*/
public static function ajax_save() {
check_ajax_referer(self::NONCE, 'nonce');
if (!self::can_change()) {
wp_send_json_error(array('message' => __('You are not allowed to change these settings.', 'wp-plugin-starter-template')), 403);
}
$key = isset($_POST['key']) ? sanitize_key(wp_unslash($_POST['key'])) : '';
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per schema in set().
$raw = isset($_POST['value']) ? wp_unslash($_POST['value']) : '';
$value = self::set($key, $raw);
if (is_wp_error($value)) {
wp_send_json_error(array('message' => $value->get_error_message()), 400);
}
/**
* Fires after a setting is saved from the admin UI.
*
* @param string $key Setting key.
* @param mixed $value Sanitized value.
*/
do_action('wpstarter_setting_saved', $key, $value);
$schema = self::schema();
$reload = !empty($schema[$key]['reload']);
wp_send_json_success(array(
'key' => $key,
'value' => $value,
'message' => $reload ? __('Saved. Reload the page to see the change.', 'wp-plugin-starter-template') : __('Saved', 'wp-plugin-starter-template'),
'reload' => $reload,
));
}
/**
* One-off migrations, once per DB_VERSION: WPStarter_Setup::migrate()
* (imports that belong to no feature), then each feature's migrate().
* The history of versions is in WPStarter_Setup::DB_VERSION.
*
* Old options are left in place so a downgrade keeps working;
* uninstall.php removes ours. Other plugins' options are never touched.
*/
public static function maybe_migrate() {
$from = (int) get_option(self::DB_VERSION_OPTION, 0);
if ($from >= self::DB_VERSION) {
return;
}
$options = get_option(self::OPTION, array());
$options = is_array($options) ? $options : array();
$options = (array) WPStarter_Setup::migrate($options, $from);
foreach (WPStarter::features() as $class) {
$options = (array) $class::migrate($options, $from);
}
$clean = self::sanitize_all($options);
update_option(self::OPTION, $clean);
// Only record the version once the settings are stored, so a failed
// write is retried on the next request instead of losing imports.
self::flush_cache();
if (get_option(self::OPTION) != $clean) { // phpcs:ignore Universal.Operators.StrictComparisons -- key order may differ.
return;
}
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
/**
* What makes this plugin WP Plugin Starter: its features, settings tabs,
* links, settings history and the helpers only it needs.
*
* The other files in includes/ and admin/ that the starter plugin also has
* (the feature registry, settings store, base feature and admin screen)
* read this class and differ from the starter's only in names. Keep
* anything that only this plugin needs here, in features or in its own
* files loaded from here.
*
* The starter has no features yet, so its settings screen shows the first
* settings tab with "No settings yet" and the Read Me tab. Add a feature:
* STANDARDS.md → Structure and README.md → Developers.
*
* @package WPStarter
* @since 1.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
final class WPStarter_Setup {
/**
* Built-in features, in the order their cards appear within each tab.
* Each lives in includes/features/class-{lowercase-hyphenated-name}.php,
* for example WPStarter_Example in class-wpstarter-example.php.
*/
const FEATURES = array();
/**
* Features that some builds leave out, loaded only when their file is
* present (for example one listed in .distignore-wporg).
*/
const OPTIONAL_FEATURES = array();
/**
* Settings version. WPStarter_Settings::maybe_migrate() runs
* migrate() below and every feature's migrate() once per version.
* After a release, bump it for a new or changed import and add a line.
*
* v1: first version.
*/
const DB_VERSION = 1;
/**
* Renamed tab slugs, old => new. Settings that still use an old slug
* land on the new tab, and old admin links open the new tab.
*/
const RENAMED_TABS = array();
/**
* Links in the settings screen header; leave one out for no button.
*
* - website: the maker's website
* - support: where people report problems (the plugin's GitHub issues)
* - donate: where people can support the maker
*
* @return array<string,string>
*/
public static function header_links() {
return array(
'website' => 'https://www.wpallstars.com/',
'support' => 'https://github.com/wpallstars/wp-plugin-starter-template-for-ai-coding/issues',
'donate' => 'https://buymeacoffee.com/marcusquinn',
);
}
/**
* Load the plugin's own helpers, before the features.
*/
public static function load() {
}
/**
* Register the helpers' hooks, after the settings store's.
*/
public static function init() {
}
/**
* Imports that belong to no feature, run once per DB_VERSION before
* the features' own (for example settings from the plugin's earlier
* names). Never write or delete other plugins' options.
*
* @param array $options Stored settings (raw, without defaults).
* @param int $from_version Stored settings version before this upgrade.
* @return array
*/
public static function migrate(array $options, $from_version) {
unset($from_version);
return $options;
}
/**
* Settings tabs, in navigation order. A tab shows only when a setting
* uses it; with no settings at all, the first one shows, empty.
*
* @return array<string,array{label:string,description:string}>
*/
public static function settings_tabs() {
return array(
'general' => array(
'label' => __('General', 'wp-plugin-starter-template'),
'description' => __('WP Plugin Starter\'s settings appear here as features are added.', 'wp-plugin-starter-template'),
),
);
}
/**
* Admin requests: load and start the plugin's own admin parts (other
* tabs with the wpstarter_admin_tabs filter, scripts with the
* wpstarter_admin_enqueue action).
*/
public static function admin() {
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* WP Plugin Starter bootstrap and feature registry. The features and anything
* else only this plugin needs come from WPStarter_Setup.
*
* @package WPStarter
* @since 0.3.0
*/
if (!defined('ABSPATH')) {
exit;
}
final class WPStarter {
/**
* Feature classes whose files are loaded: WPStarter_Setup::FEATURES,
* plus each of WPStarter_Setup::OPTIONAL_FEATURES this build has.
*
* @var string[]
*/
private static $core_features = array();
/**
* Resolved feature classes.
*
* @var string[]|null
*/
private static $features = null;
/**
* Load files and register hooks.
*/
public static function load() {
require_once WPSTARTER_DIR . 'includes/class-wpstarter-settings.php';
require_once WPSTARTER_DIR . 'includes/class-wpstarter-feature.php';
require_once WPSTARTER_DIR . 'includes/class-wpstarter-setup.php';
WPStarter_Setup::load();
foreach (WPStarter_Setup::FEATURES as $class) {
require_once self::feature_file($class);
self::$core_features[] = $class;
}
// Features that some builds leave out (the WordPress.org build has
// no GitHub updates) load only when their file is present.
foreach (WPStarter_Setup::OPTIONAL_FEATURES as $class) {
if (is_readable(self::feature_file($class))) {
require_once self::feature_file($class);
self::$core_features[] = $class;
}
}
// The shared GitHub updater (GitHub builds only): registers this
// copy; the newest copy on the site loads on plugins_loaded.
if (is_readable(WPSTARTER_DIR . 'includes/github-updater/load.php')) {
require_once WPSTARTER_DIR . 'includes/github-updater/load.php';
}
WPStarter_Settings::init();
WPStarter_Setup::init();
// Priority 0, added after WPStarter_Settings::maybe_migrate() so features
// read migrated values, and early enough to hook widgets_init (init:1).
add_action('init', array(__CLASS__, 'boot_features'), 0);
if (is_admin()) {
require_once WPSTARTER_DIR . 'admin/settings.php';
}
}
/**
* File of a feature class.
*
* @param string $class Class name.
* @return string
*/
private static function feature_file($class) {
return WPSTARTER_DIR . 'includes/features/class-' . str_replace('_', '-', strtolower($class)) . '.php';
}
/**
* Registered feature classes.
*
* @return string[]
*/
public static function features() {
if (null === self::$features) {
/**
* Filter the feature classes. Each must extend WPStarter_Feature.
*
* @param string[] $features Class names.
*/
$features = (array) apply_filters('wpstarter_features', self::$core_features);
self::$features = array_values(array_filter($features, function ($class) {
return is_string($class) && class_exists($class) && is_subclass_of($class, 'WPStarter_Feature');
}));
}
return self::$features;
}
/**
* Boot every feature.
*/
public static function boot_features() {
foreach (self::features() as $class) {
$class::boot();
}
}
/**
* Plugin deactivated: let features that changed files or scheduled
* work outside WordPress's options undo it (a static deactivate() method).
*
* @param bool $network_wide Deactivated for the whole network.
*/
public static function deactivate($network_wide = false) {
foreach (self::features() as $class) {
if (method_exists($class, 'deactivate')) {
$class::deactivate((bool) $network_wide);
}
}
}
}
@@ -0,0 +1,759 @@
<?php
/**
* Shared GitHub updater: updates from GitHub releases for every installed
* plugin that names its GitHub repository.
*
* Loaded by load.php, which picks the newest copy on the site; see there.
* Only in builds made from GitHub releases: the WordPress.org build leaves
* this folder out.
*
* - A plugin takes part by naming its repository in its main file's header:
* `GitHub Plugin URI: owner/repo` (the header Git Updater reads, so the
* two stay compatible). `Release Asset: true` means "install only the zip
* attached to the release, never the source code".
* - WordPress does the rest itself: this only tells it that a newer release
* exists and where its zip is. The Updates screen, auto-updates, "View
* details", the download, the install and the rollback on failure are
* WordPress's own. Nothing is removed from or blocked in its update check.
* - The latest release of each repository is asked for at most every 12
* hours (an hour after a failure), when WordPress checks for updates, and
* again when someone presses "Check again" on the Updates screen.
* - Public repositories are read from github.com's own pages (the
* releases/latest redirect, the asset's download address and the main
* file on raw.githubusercontent.com), not the API: without a token the API
* allows 60 requests an hour per server address, shared by every site on
* a host. The asset must be named {folder}-{version}.zip.
* - Private repositories need a GitHub token in wp-config.php
* (WPALLSTARS_GITHUB_TOKEN) or from the `wpallstars_github_token` filter,
* and are read through the API. The token is sent only to api.github.com,
* never stored and never shown.
* - Plugins also on WordPress.org keep updating from there unless the
* `wpallstars_github_updater_early` filter returns true.
* - While Git Updater is active, this waits and Git Updater does the job.
* The `wpallstars_github_updater_enabled` filter can turn it off too.
*
* @package WPStarter
*/
if (!defined('ABSPATH')) {
exit;
}
final class WPAllStars_GitHub_Updater {
/** Site transient: latest release of each repository, keyed by owner/repo. */
const CACHE = 'wpallstars_github_releases';
/** How long a release answer and a failed request are kept. */
const FRESH = 12 * HOUR_IN_SECONDS;
const RETRY = HOUR_IN_SECONDS;
const RECENT = MINUTE_IN_SECONDS;
/** Update IDs this file adds, so its own entries can be told apart. */
const ID_PREFIX = 'github.com/';
/**
* Release answers read this request.
*
* @var array|null
*/
private static $cache = null;
/**
* Register hooks (plugins_loaded). Whether to run is decided on init,
* once plugins have added their filters.
*/
public static function load() {
add_action('init', array(__CLASS__, 'boot'), 20);
}
/**
* Register the update hooks, unless switched off or Git Updater is active.
*/
public static function boot() {
/**
* Whether to add GitHub releases to WordPress's update check.
*
* @param bool $enabled False while Git Updater is active.
*/
if (!apply_filters('wpallstars_github_updater_enabled', !self::git_updater_active())) {
return;
}
// Update checks run in the admin, in cron and in WP-CLI.
add_filter('pre_set_site_transient_update_plugins', array(__CLASS__, 'add_updates'));
add_filter('plugins_api', array(__CLASS__, 'plugin_information'), 20, 3);
add_filter('upgrader_pre_download', array(__CLASS__, 'private_download'), 10, 3);
add_filter('upgrader_source_selection', array(__CLASS__, 'fix_folder'), 10, 4);
}
/**
* Whether Git Updater is active on this site or network-wide.
*
* @return bool
*/
private static function git_updater_active() {
$files = (array) get_option('active_plugins', array());
if (is_multisite()) {
$files = array_merge($files, array_keys((array) get_site_option('active_sitewide_plugins', array())));
}
foreach ($files as $file) {
if ('git-updater' === dirname((string) $file)) {
return true;
}
}
return false;
}
/**
* Installed plugins that name a GitHub repository.
*
* @return array<string,array{repo:string,asset_only:bool,version:string,name:string}> Plugin file => details.
*/
public static function plugins() {
static $found = null;
if (null !== $found) {
return $found;
}
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$found = array();
foreach (get_plugins() as $file => $data) {
// Plugins in a folder only: an update replaces the whole folder.
if ('.' === dirname($file)) {
continue;
}
$headers = get_file_data(WP_PLUGIN_DIR . '/' . $file, array(
'repo' => 'GitHub Plugin URI',
'asset' => 'Release Asset',
));
$repo = self::repo_name($headers['repo']);
if ('' === $repo) {
continue;
}
$found[$file] = array(
'repo' => $repo,
'asset_only' => in_array(strtolower(trim($headers['asset'])), array('true', 'yes', '1'), true),
'version' => isset($data['Version']) ? (string) $data['Version'] : '',
'name' => isset($data['Name']) ? (string) $data['Name'] : $file,
);
}
/**
* Filter the plugins updated from GitHub releases.
*
* @param array $found Plugin file => array(repo, asset_only, version, name).
*/
$filtered = (array) apply_filters('wpallstars_github_plugins', $found);
$found = array();
foreach ($filtered as $file => $plugin) {
$repo = is_array($plugin) && isset($plugin['repo']) ? self::repo_name($plugin['repo']) : '';
if ('' === $repo || '.' === dirname((string) $file)) {
continue;
}
$found[(string) $file] = array(
'repo' => $repo,
'asset_only' => !empty($plugin['asset_only']),
'version' => isset($plugin['version']) ? (string) $plugin['version'] : '',
'name' => isset($plugin['name']) ? (string) $plugin['name'] : (string) $file,
);
}
return $found;
}
/**
* owner/repo from a header value ("owner/repo" or a github.com address).
*
* @param mixed $value Header value.
* @return string owner/repo, or '' when it is not one.
*/
public static function repo_name($value) {
$value = trim((string) $value);
$value = preg_replace('#^(?:https?://)?(?:www\.)?github\.com/#i', '', $value);
$value = preg_replace('#(?:\.git)?/*$#', '', (string) $value);
return preg_match('#^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$#', (string) $value) ? (string) $value : '';
}
/**
* Token for a repository, if the site has one.
*
* @param string $repo owner/repo.
* @return string
*/
private static function token($repo) {
$token = defined('WPALLSTARS_GITHUB_TOKEN') ? (string) WPALLSTARS_GITHUB_TOKEN : '';
/**
* Filter the GitHub token used for a repository (private repositories
* need one with read access to its contents). Return '' for none.
*
* @param string $token Token.
* @param string $repo owner/repo.
*/
return trim((string) apply_filters('wpallstars_github_token', $token, $repo));
}
/**
* GET from the GitHub API.
*
* @param string $repo owner/repo (for the token).
* @param string $path Path after /repos/{repo}.
* @param string $accept Accept header.
* @return array|WP_Error
*/
private static function api_get($repo, $path, $accept = 'application/vnd.github+json') {
$headers = array(
'Accept' => $accept,
'X-GitHub-Api-Version' => '2022-11-28',
);
$token = self::token($repo);
if ('' !== $token) {
$headers['Authorization'] = 'Bearer ' . $token;
}
// No redirects: the token must never travel to another address.
return wp_safe_remote_get('https://api.github.com/repos/' . $repo . $path, array(
'timeout' => 10,
'redirection' => 0,
'headers' => $headers,
));
}
/**
* Stored release answers.
*
* @return array
*/
private static function cache() {
if (null === self::$cache) {
$stored = get_site_transient(self::CACHE);
self::$cache = is_array($stored) ? $stored : array();
}
return self::$cache;
}
/**
* Whether someone pressed "Check again" on the Updates screen.
*
* @return bool
*/
private static function forced() {
global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- core's own link; it only refreshes data.
return is_admin() && 'update-core.php' === $pagenow && !empty($_GET['force-check']) && current_user_can('update_plugins');
}
/**
* Latest release of a repository, from the cache or GitHub.
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder (picks the release asset).
* @param string $main Main file name in the folder (reads its requirements).
* @return array|null Release, or null when there is none or GitHub did not answer.
*/
public static function release($repo, $folder, $main) {
$cache = self::cache();
// The asset and requirements depend on the plugin, not only the repository.
$key = $repo . '|' . $folder . '/' . $main;
$entry = isset($cache[$key]) && is_array($cache[$key]) ? $cache[$key] : null;
$age = $entry && isset($entry['checked']) ? time() - (int) $entry['checked'] : PHP_INT_MAX;
$keep = $entry && !empty($entry['failed']) ? self::RETRY : self::FRESH;
if ($entry && $age < $keep && !(self::forced() && $age > self::RECENT)) {
return isset($entry['release']) ? $entry['release'] : null;
}
$release = self::fetch($repo, $folder, $main);
if (is_wp_error($release)) {
// Keep the last good answer, and try again in an hour.
$entry = array(
'checked' => time(),
'failed' => $release->get_error_message(),
'release' => $entry && isset($entry['release']) ? $entry['release'] : null,
);
} else {
$entry = array('checked' => time(), 'release' => $release);
}
self::$cache[$key] = $entry;
set_site_transient(self::CACHE, self::$cache, 2 * DAY_IN_SECONDS);
return $entry['release'];
}
/**
* Ask GitHub for a repository's latest release.
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param string $main Main file name.
* @return array|null|WP_Error Release, null when there is no usable one, or the error.
*/
private static function fetch($repo, $folder, $main) {
return '' !== self::token($repo) ? self::fetch_api($repo, $folder, $main) : self::fetch_web($repo, $folder, $main);
}
/**
* An error for a GitHub answer that was not the expected one.
*
* @param int $code HTTP status code.
* @return WP_Error
*/
private static function status_error($code) {
/* translators: %d: HTTP status code */
return new WP_Error('wpallstars_github', sprintf(__('GitHub answered with status %d.', 'wp-plugin-starter-template'), $code));
}
/**
* Latest release from github.com's own pages, for public repositories.
* The GitHub API allows 60 requests an hour per server address without
* a token, which hosts share between many sites; these pages do not
* count towards it.
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param string $main Main file name.
* @return array|null|WP_Error
*/
private static function fetch_web($repo, $folder, $main) {
// Redirects to the newest release that is not a draft or pre-release.
$response = wp_safe_remote_head('https://github.com/' . $repo . '/releases/latest', array(
'timeout' => 10,
'redirection' => 0,
));
if (is_wp_error($response)) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code($response);
$location = (string) wp_remote_retrieve_header($response, 'location');
if (404 === $code) {
// No such public repository (private ones need a token).
return null;
}
if ($code < 300 || $code > 399) {
return self::status_error($code);
}
if (!preg_match('#^https://github\.com/' . preg_quote($repo, '#') . '/releases/tag/([^/?\#]+)$#i', $location, $match)) {
// No releases yet: GitHub sends the releases list instead.
return null;
}
$tag = rawurldecode($match[1]);
$version = preg_replace('/^v/i', '', $tag);
if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $version)) {
return null;
}
// The release zip, by its usual name: {folder}-{version}.zip.
$asset = null;
$download = 'https://github.com/' . $repo . '/releases/download/' . rawurlencode($tag) . '/' . rawurlencode($folder . '-' . $version . '.zip');
$head = wp_safe_remote_head($download, array('timeout' => 10, 'redirection' => 0));
if (is_wp_error($head)) {
return $head;
}
$code = (int) wp_remote_retrieve_response_code($head);
if ($code >= 300 && $code < 400) {
$asset = array('public' => $download, 'api' => '');
} elseif (404 !== $code) {
return self::status_error($code);
}
$release = array(
'tag' => $tag,
'version' => $version,
'url' => $location,
'published' => '',
'notes' => '',
'asset' => $asset,
'zipball' => 'https://github.com/' . $repo . '/archive/refs/tags/' . rawurlencode($tag) . '.zip',
);
$file = wp_safe_remote_get('https://raw.githubusercontent.com/' . $repo . '/' . rawurlencode($tag) . '/' . rawurlencode($main), array(
'timeout' => 10,
'headers' => array('Range' => 'bytes=0-8191'),
));
$body = !is_wp_error($file) && in_array((int) wp_remote_retrieve_response_code($file), array(200, 206), true) ? (string) wp_remote_retrieve_body($file) : '';
return $release + self::requirements($body);
}
/**
* Latest release from the GitHub API, with a token (private repositories).
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param string $main Main file name.
* @return array|null|WP_Error
*/
private static function fetch_api($repo, $folder, $main) {
// The newest release that is not a draft or pre-release.
$response = self::api_get($repo, '/releases/latest');
if (is_wp_error($response)) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code($response);
if (404 === $code) {
// No releases yet, or a private repository without a token.
return null;
}
if (200 !== $code) {
return self::status_error($code);
}
$data = json_decode(wp_remote_retrieve_body($response), true);
$tag = is_array($data) && isset($data['tag_name']) ? (string) $data['tag_name'] : '';
$version = preg_replace('/^v/i', '', $tag);
// Numbers only: anything else is not a finished version.
if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $version)) {
return null;
}
$release = array(
'tag' => $tag,
'version' => $version,
'url' => isset($data['html_url']) ? (string) $data['html_url'] : 'https://github.com/' . $repo . '/releases',
'published' => isset($data['published_at']) ? (string) $data['published_at'] : '',
'notes' => isset($data['body']) ? substr((string) $data['body'], 0, 20000) : '',
'asset' => self::pick_asset($repo, $folder, isset($data['assets']) && is_array($data['assets']) ? $data['assets'] : array()),
'zipball' => 'https://api.github.com/repos/' . $repo . '/zipball/' . rawurlencode($tag),
);
$file = self::api_get($repo, '/contents/' . rawurlencode($main) . '?ref=' . rawurlencode($tag), 'application/vnd.github.raw+json');
$body = !is_wp_error($file) && 200 === (int) wp_remote_retrieve_response_code($file) ? (string) wp_remote_retrieve_body($file) : '';
return $release + self::requirements($body);
}
/**
* The release zip for a plugin folder: a .zip asset whose name starts
* with the folder name (never the "wordpress-org-…" build).
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param array $assets Release assets.
* @return array{public:string,api:string}|null Download addresses.
*/
private static function pick_asset($repo, $folder, array $assets) {
foreach ($assets as $asset) {
$name = isset($asset['name']) ? (string) $asset['name'] : '';
if (0 !== stripos($name, $folder) || '.zip' !== strtolower(substr($name, -4))) {
continue;
}
$public = isset($asset['browser_download_url']) ? (string) $asset['browser_download_url'] : '';
$api = isset($asset['id']) ? 'https://api.github.com/repos/' . $repo . '/releases/assets/' . (int) $asset['id'] : '';
if (0 === strpos($public, 'https://github.com/' . $repo . '/releases/download/')) {
return array('public' => $public, 'api' => $api);
}
}
return null;
}
/**
* "Requires at least" and "Requires PHP" of the released main file, so
* WordPress does not offer an update the site cannot run.
*
* @param string $file Start of the released main file ('' when unknown).
* @return array{requires:string,requires_php:string}
*/
private static function requirements($file) {
$found = array('requires' => '', 'requires_php' => '');
$head = substr((string) $file, 0, 8192);
foreach (array('requires' => 'Requires at least', 'requires_php' => 'Requires PHP') as $key => $header) {
if (preg_match('/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote($header, '/') . ':(.*)$/mi', $head, $match)) {
$value = trim(preg_replace('/\s*(?:\*\/|\?>).*/', '', $match[1]));
if (preg_match('/^[0-9]+(\.[0-9]+)*$/', $value)) {
$found[$key] = $value;
}
}
}
return $found;
}
/**
* Download address WordPress installs from.
*
* @param string $repo owner/repo.
* @param array $release Release.
* @param bool $asset_only Install the release asset only.
* @return string '' when the release has nothing to install.
*/
private static function package($repo, array $release, $asset_only) {
$private = '' !== self::token($repo);
if (!empty($release['asset'])) {
// Assets of private repositories download through the API (see private_download()).
return $private && $release['asset']['api'] ? $release['asset']['api'] : $release['asset']['public'];
}
return $asset_only ? '' : (string) $release['zipball'];
}
/**
* Whether WordPress.org offers updates for a plugin.
*
* @param object $transient update_plugins.
* @param string $file Plugin file.
* @return bool
*/
private static function on_wordpress_org($transient, $file) {
foreach (array('response', 'no_update') as $list) {
if (isset($transient->{$list}[$file])) {
$item = (object) $transient->{$list}[$file];
$id = isset($item->id) ? (string) $item->id : '';
if (0 !== strpos($id, self::ID_PREFIX)) {
return true;
}
}
}
return false;
}
/**
* Tell WordPress about GitHub releases when it saves its update check.
*
* @param mixed $transient update_plugins.
* @return mixed
*/
public static function add_updates($transient) {
if (!is_object($transient)) {
return $transient;
}
/**
* Whether plugins also on WordPress.org take each version from GitHub
* as soon as it is released, instead of waiting for WordPress.org.
*
* @param bool $early Default false.
*/
$early = (bool) apply_filters('wpallstars_github_updater_early', false);
foreach (self::plugins() as $file => $plugin) {
if (!$early && self::on_wordpress_org($transient, $file)) {
continue;
}
$folder = dirname($file);
$release = self::release($plugin['repo'], $folder, basename($file));
$current = isset($transient->checked[$file]) ? (string) $transient->checked[$file] : $plugin['version'];
if (!$release) {
// Keep WordPress.org's answer, or say nothing.
continue;
}
$dot_org = isset($transient->response[$file]->new_version) ? (string) $transient->response[$file]->new_version : '';
if ('' !== $dot_org && 0 !== strpos((string) ($transient->response[$file]->id ?? ''), self::ID_PREFIX) && version_compare($dot_org, $release['version'], '>=')) {
// WordPress.org already offers this version or a newer one.
continue;
}
$item = (object) array(
'id' => self::ID_PREFIX . $plugin['repo'],
'slug' => $folder,
'plugin' => $file,
'new_version' => $release['version'],
'url' => 'https://github.com/' . $plugin['repo'],
'package' => self::package($plugin['repo'], $release, $plugin['asset_only']),
'requires' => $release['requires'],
'requires_php' => $release['requires_php'],
'tested' => '',
'icons' => array(),
'banners' => array(),
'banners_rtl' => array(),
'compatibility' => new stdClass(),
);
if (!is_array($transient->response ?? null)) {
$transient->response = array();
}
if (!is_array($transient->no_update ?? null)) {
$transient->no_update = array();
}
if ('' === $item->package && self::on_wordpress_org($transient, $file)) {
// Nothing to install from GitHub: keep WordPress.org's answer.
continue;
}
unset($transient->response[$file], $transient->no_update[$file]);
if ('' !== $item->package && version_compare($release['version'], $current, '>')) {
$transient->response[$file] = $item;
} else {
// Listed as up to date, so the Plugins screen offers auto-updates.
$transient->no_update[$file] = $item;
}
}
return $transient;
}
/**
* The plugin a "View details" request is about, if it is ours to answer.
*
* @param string $slug Plugin folder.
* @return string Plugin file, or ''.
*/
private static function file_for_slug($slug) {
$updates = get_site_transient('update_plugins');
foreach (self::plugins() as $file => $plugin) {
if (dirname($file) !== $slug) {
continue;
}
foreach (array('response', 'no_update') as $list) {
if (is_object($updates) && isset($updates->{$list}[$file]->id) && 0 === strpos((string) $updates->{$list}[$file]->id, self::ID_PREFIX)) {
return $file;
}
}
}
return '';
}
/**
* "View details" for plugins updated from GitHub.
*
* @param false|object|array $result Result so far.
* @param string $action plugins_api action.
* @param object $args Request arguments.
* @return false|object|array
*/
public static function plugin_information($result, $action, $args) {
if ('plugin_information' !== $action || !is_object($args) || empty($args->slug)) {
return $result;
}
$file = self::file_for_slug((string) $args->slug);
if ('' === $file) {
return $result;
}
$plugins = self::plugins();
$plugin = $plugins[$file];
$release = self::release($plugin['repo'], dirname($file), basename($file));
$data = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, true);
$sections = array(
'description' => wpautop(esc_html(isset($data['Description']) ? wp_strip_all_tags($data['Description']) : '')),
);
if ($release) {
$sections['changelog'] = '<h4>' . esc_html($release['version']) . '</h4>'
. ('' !== trim($release['notes']) ? self::notes_html($release['notes']) : '')
. sprintf('<p><a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a></p>', esc_url($release['url']), esc_html__('Release notes on GitHub', 'wp-plugin-starter-template'));
}
return (object) array(
'name' => $plugin['name'],
'slug' => dirname($file),
'version' => $release ? $release['version'] : $plugin['version'],
'author' => isset($data['Author']) ? $data['Author'] : '',
'homepage' => 'https://github.com/' . $plugin['repo'],
'requires' => $release ? $release['requires'] : '',
'requires_php' => $release ? $release['requires_php'] : '',
'last_updated' => $release ? $release['published'] : '',
'download_link' => $release ? self::package($plugin['repo'], $release, $plugin['asset_only']) : '',
'sections' => $sections,
'banners' => array(),
'external' => true,
);
}
/**
* Release notes (Markdown) as simple HTML: headings, lists, paragraphs,
* bold and code. Everything is escaped first.
*
* @param string $markdown Release notes.
* @return string
*/
private static function notes_html($markdown) {
$html = '';
$list = false;
foreach (preg_split('/\r\n|\r|\n/', (string) $markdown) as $line) {
$line = rtrim($line);
$text = esc_html(ltrim(preg_replace('/^(#{1,6}|[-*+])\s+/', '', trim($line))));
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
$text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
$item = (bool) preg_match('/^\s*[-*+]\s+/', $line);
if ($list && !$item) {
$html .= '</ul>';
$list = false;
}
if ('' === trim($line)) {
continue;
}
if ($item) {
if (!$list) {
$html .= '<ul>';
$list = true;
}
$html .= '<li>' . $text . '</li>';
} elseif (preg_match('/^#{1,6}\s/', $line)) {
$html .= '<h4>' . $text . '</h4>';
} else {
$html .= '<p>' . $text . '</p>';
}
}
return $html . ($list ? '</ul>' : '');
}
/**
* Downloads from private repositories: ask the API with the token, then
* fetch the file from where GitHub sends us without it.
*
* @param mixed $reply False to let WordPress download.
* @param string $package Download address.
* @param WP_Upgrader $upgrader Upgrader.
* @return mixed File path, WP_Error or $reply.
*/
public static function private_download($reply, $package, $upgrader) {
if (false !== $reply || !is_string($package) || !preg_match('#^https://api\.github\.com/repos/([^/]+/[^/]+)/(?:releases/assets/[0-9]+|zipball/[^/?]+)$#', $package, $match)) {
return $reply;
}
$repo = $match[1];
$known = false;
foreach (self::plugins() as $plugin) {
$known = $known || $plugin['repo'] === $repo;
}
$token = self::token($repo);
if (!$known || '' === $token) {
return $reply;
}
$response = wp_safe_remote_get($package, array(
'timeout' => 30,
'redirection' => 0,
'headers' => array(
'Accept' => false !== strpos($package, '/releases/assets/') ? 'application/octet-stream' : 'application/vnd.github+json',
'Authorization' => 'Bearer ' . $token,
),
));
if (is_wp_error($response)) {
return $response;
}
$location = (string) wp_remote_retrieve_header($response, 'location');
if ('' === $location || !in_array((int) wp_remote_retrieve_response_code($response), array(301, 302, 303, 307, 308), true)) {
return new WP_Error('wpallstars_github_download', __('GitHub did not give a download address. Check the token’s access to the repository.', 'wp-plugin-starter-template'));
}
// A signed, short-lived address: no token needed (or wanted) there.
return download_url($location, 300);
}
/**
* Keep a plugin in its folder when the zip's top folder has another name
* (GitHub's source zips are named owner-repo-commit).
*
* @param string|WP_Error $source Unpacked folder.
* @param string $remote_source Folder it was unpacked in.
* @param WP_Upgrader $upgrader Upgrader.
* @param array $hook_extra Context.
* @return string|WP_Error
*/
public static function fix_folder($source, $remote_source, $upgrader, $hook_extra = array()) {
global $wp_filesystem;
if (is_wp_error($source) || empty($hook_extra['plugin']) || !$wp_filesystem) {
return $source;
}
$file = (string) $hook_extra['plugin'];
if (!isset(self::plugins()[$file])) {
return $source;
}
$folder = dirname($file);
if (basename(untrailingslashit($source)) === $folder) {
return $source;
}
$target = trailingslashit($remote_source) . $folder . '/';
$from = untrailingslashit($source);
if ($from === untrailingslashit($remote_source)) {
// A zip without a top folder: move its files aside, then into the folder.
$from = untrailingslashit($remote_source) . '-github-updater';
if (!$wp_filesystem->move(untrailingslashit($remote_source), $from, true) || !$wp_filesystem->mkdir(untrailingslashit($remote_source), FS_CHMOD_DIR)) {
return new WP_Error('wpallstars_github_folder', __('The update could not be unpacked into the plugin’s folder.', 'wp-plugin-starter-template'));
}
}
if (!$wp_filesystem->move($from, untrailingslashit($target), true)) {
return new WP_Error('wpallstars_github_folder', __('The update could not be unpacked into the plugin’s folder.', 'wp-plugin-starter-template'));
}
return $target;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
/**
* Shared GitHub updater: registers this copy.
*
* Every plugin made from the starter carries a copy of includes/github-updater/.
* Each copy registers its version here when its plugin loads; on
* plugins_loaded only the newest copy loads, once, and serves every installed
* plugin with a `GitHub Plugin URI` header. So one update check covers all of
* them, however many carry a copy.
*
* The WordPress.org build leaves this folder out (.distignore-wporg): plugins
* hosted there may not install or update code from anywhere else.
*
* The starter holds the master copy. Change it there, raise the version
* below with every change, then copy it to each plugin (scripts/sync-core.sh).
* Plugins change what it does only through its filters, never by calling the
* class: another plugin's newer or older copy may be the one that runs.
*
* @package WPStarter
*/
if (!defined('ABSPATH')) {
exit;
}
if (!isset($GLOBALS['wpallstars_github_updater']) || !is_array($GLOBALS['wpallstars_github_updater'])) {
$GLOBALS['wpallstars_github_updater'] = array();
}
// Version of this copy => its class file.
$GLOBALS['wpallstars_github_updater']['1.0.0'] = __DIR__ . '/class-wpallstars-github-updater.php';
if (!function_exists('wpallstars_github_updater_load')) {
/**
* Load the newest registered copy of the updater (plugins_loaded).
*/
function wpallstars_github_updater_load() {
if (class_exists('WPAllStars_GitHub_Updater', false) || empty($GLOBALS['wpallstars_github_updater'])) {
return;
}
$copies = (array) $GLOBALS['wpallstars_github_updater'];
uksort($copies, 'version_compare');
$file = (string) end($copies);
if (is_readable($file)) {
require_once $file;
WPAllStars_GitHub_Updater::load();
}
}
// Every plugin's main file has run by then, so every copy is registered.
add_action('plugins_loaded', 'wpallstars_github_updater_load', 1);
}
+109
View File
@@ -0,0 +1,109 @@
<?xml version="1.0"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="WPStarter" xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
<description>
WP Plugin Starter: what WordPress.org reviewers and Plugin Check look for
(security, the database, internationalisation, deprecated and
discouraged APIs, prefixes) plus compatibility with PHP 7.4 and
WordPress 6.2. Whitespace and formatting follow the code's own style
(4 spaces, no padding inside parentheses) and are not checked here.
Every finding fails the check. When code is right as it is, say why
on the line: // phpcs:ignore Sniff.Name -- reason.
Run: composer phpcs, or scripts/lint.sh.
</description>
<file>.</file>
<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/.cache/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<exclude-pattern>/dist/*</exclude-pattern>
<exclude-pattern>/reference-plugins/*</exclude-pattern>
<exclude-pattern>/project-documents/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="parallel" value="8"/>
<arg value="sp"/>
<arg name="colors"/>
<!-- Minimum versions (wp-plugin-starter-template.php and readme.txt). -->
<config name="testVersion" value="7.4-"/>
<config name="minimum_wp_version" value="6.2"/>
<!-- PHP 7.4 syntax and functions, knowing WordPress's polyfills. Everywhere, scripts included. -->
<rule ref="PHPCompatibilityWP"/>
<!--
WordPress rules apply to the plugin, not to scripts/*.php: those run with
the PHP CLI on a developer's machine or in CI, never in WordPress, and are
not in the release zips.
-->
<!-- Security: escape output, verify nonces, sanitise input, safe redirects. -->
<rule ref="WordPress.Security">
<exclude-pattern>/scripts/*</exclude-pattern>
</rule>
<!-- Database: prepared queries, WordPress's database layer. -->
<rule ref="WordPress.DB">
<exclude-pattern>/scripts/*</exclude-pattern>
</rule>
<!-- WordPress APIs: deprecated, discouraged, alternatives, i18n, enqueues. -->
<rule ref="WordPress.WP">
<exclude-pattern>/scripts/*</exclude-pattern>
<!-- Capabilities from other plugins are checked in review. -->
<exclude name="WordPress.WP.Capabilities.Unknown"/>
<!-- Copy is checked in review; "WordPress" in slugs and URLs is not a typo. -->
<exclude name="WordPress.WP.CapitalPDangit"/>
</rule>
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="wp-plugin-starter-template"/>
</property>
</properties>
</rule>
<rule ref="WordPress.DateTime">
<exclude-pattern>/scripts/*</exclude-pattern>
</rule>
<rule ref="WordPress.PHP">
<exclude-pattern>/scripts/*</exclude-pattern>
<!-- Yoda conditions are a formatting preference. -->
<exclude name="WordPress.PHP.YodaConditions"/>
</rule>
<rule ref="WordPress.CodeAnalysis">
<exclude-pattern>/scripts/*</exclude-pattern>
</rule>
<!-- Everything global is prefixed, so it cannot clash with other plugins. -->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<exclude-pattern>/scripts/*</exclude-pattern>
<properties>
<property name="prefixes" type="array">
<element value="wpstarter"/>
<element value="WPStarter"/>
<!-- The shared GitHub updater (includes/github-updater/), the same in every plugin. -->
<element value="wpallstars"/>
<element value="WPAllStars"/>
</property>
</properties>
</rule>
<rule ref="WordPress.NamingConventions.ValidHookName">
<exclude-pattern>/scripts/*</exclude-pattern>
<!-- Some plugins name their hooks with slashes; firing theirs needs their names. -->
<exclude name="WordPress.NamingConventions.ValidHookName.UseUnderscores"/>
</rule>
<!-- PHP that WordPress.org does not allow, or code that cannot be right. -->
<rule ref="Generic.PHP.BacktickOperator"/>
<rule ref="Generic.PHP.DisallowShortOpenTag"/>
<rule ref="Generic.PHP.DisallowAlternativePHPTags"/>
<rule ref="Generic.PHP.DiscourageGoto"/>
<rule ref="Generic.Files.ByteOrderMark"/>
<rule ref="Generic.Files.LineEndings"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement"/>
<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
<rule ref="Generic.CodeAnalysis.JumbledIncrementer"/>
<rule ref="Squiz.PHP.NonExecutableCode"/>
<rule ref="Squiz.PHP.Eval"/>
</ruleset>
+2
View File
@@ -0,0 +1,2 @@
parameters:
ignoreErrors: []
+22
View File
@@ -0,0 +1,22 @@
# PHPStan settings for WP Plugin Starter's own files, added to phpstan.neon.dist
# (which holds the settings every plugin made from the starter shares).
# Add the plugin's own folders under paths (for example blocks) and each
# ignore with the identifier, the file and the reason.
parameters:
ignoreErrors:
# The starter has no features, tabs to rename or helpers yet:
# WPStarter_Setup's lists are empty and its methods do nothing, so
# PHPStan reads the core code that uses them as having no effect.
# A plugin made from the starter fills them and drops these ignores.
-
identifiers:
- foreach.emptyArray
- staticMethod.resultUnused
path: includes/class-wpstarter.php
-
identifier: staticMethod.resultUnused
path: admin/settings.php
-
identifier: function.impossibleType
message: '#array_key_exists\(\) with mixed and array\{\}#'
path: includes/class-wpstarter-settings.php
+36
View File
@@ -0,0 +1,36 @@
# PHPStan: finds calls to functions, methods and classes that do not exist,
# wrong argument counts and types, and code that cannot run, using the
# WordPress stubs (szepeviktor/phpstan-wordpress) and PHP 7.4's functions.
#
# phpstan-baseline.neon is empty: every finding fails the check. Fix the
# code. Where PHPStan or the stubs are wrong, add an entry under
# ignoreErrors with the identifier, the file and the reason: here for the
# files every plugin made from the starter shares, in phpstan-plugin.neon
# for this plugin's own files and paths. Never put findings in the baseline
# to get a change through.
includes:
- phpstan-baseline.neon
- phpstan-plugin.neon
parameters:
level: 5
phpVersion: 70400
# Result cache, kept between CI runs so only changed files are analysed again.
tmpDir: .cache/phpstan
paths:
- wp-plugin-starter-template.php
- uninstall.php
- includes
- admin
bootstrapFiles:
- scripts/phpstan-bootstrap.php
reportUnmatchedIgnoredErrors: true
treatPhpDocTypesAsCertain: false
ignoreErrors:
# Closures that change variables passed by reference (use (&$x)):
# PHPStan does not see the changes, so it reads them as never set.
-
identifiers:
- if.alwaysFalse
- identical.alwaysFalse
path: admin/includes/class-readme-manager.php
+52
View File
@@ -0,0 +1,52 @@
=== WP Plugin Starter ===
Contributors: wpallstars
Donate link: https://buymeacoffee.com/marcusquinn
Tags: starter, boilerplate, settings, developer, template
Requires at least: 6.2
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 1.0.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
A clean start for a WordPress plugin: a settings screen, a Read Me tab, updates from GitHub and release scripts, ready for your features.
== Description ==
WP Plugin Starter is what wpallstars plugins are made from. It has no features of its own: it holds the parts every plugin needs, so a new plugin starts with them working.
* **A settings screen** (Settings → WP Plugin Starter) that features fill by declaring their settings, saved instantly, searchable, in tabs. With no features yet it shows one empty tab.
* **A Read Me tab** that shows the plugin's README.md, banner included.
* **Features as classes**, off by default, with settings, hooks, one-off imports from the plugins they replace and clean uninstall.
* **Release and check scripts**: lint, smoke test, release build, preflight and Plugin Check.
Start a plugin from it on GitHub (wpallstars/wp-plugin-starter-template-for-ai-coding): the Read Me tab explains how.
= Built with AI =
WP Plugin Starter is built and maintained with aidevops (https://aidevops.sh), the same developer's open-source AI harness for creating and managing anything online with AI.
== Installation ==
1. Upload the `wp-plugin-starter-template` folder to `/wp-content/plugins/`, or install the zip from Plugins → Add New → Upload Plugin.
2. Activate it, then open Settings → WP Plugin Starter.
== Frequently Asked Questions ==
= Does it do anything on its own? =
No. It adds an empty settings screen and a Read Me tab, ready for a plugin's features.
= Does it contact other services? =
No. The WordPress.org build contacts nothing outside WordPress.
== Changelog ==
= 1.0.0 =
* A fresh start, made from the parts every wpallstars plugin needs: settings screen, Read Me tab, features as classes, release and check scripts. Earlier versions are retired; start again from this one.
== Upgrade Notice ==
= 1.0.0 =
A fresh start. Earlier versions of the starter are retired; start a new plugin from this one.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Build the plugin's banner from its source, .wordpress-org/banner.svg.
#
# admin/images/banner.svg
# Shipped with the plugin, shown at the top of the Read Me tab (and of
# README.md on GitHub). Text is turned into shapes, so it looks the same
# without the Zilla Slab font.
# .wordpress-org/banner-772x250.png
# .wordpress-org/banner-1544x500.png
# For the WordPress.org SVN assets/ folder (not in the plugin zip).
#
# Usage: scripts/build-banner.sh
#
# Needs Inkscape 1.x (INKSCAPE=/path/to/inkscape to choose one) and the Zilla Slab
# font installed (wpallstars.com's heading font).
set -euo pipefail
SOURCE=".wordpress-org/banner.svg"
die() {
local message="$1"
printf 'build-banner: %s\n' "$message" >&2
exit 1
}
find_inkscape() {
if [[ -n "${INKSCAPE:-}" ]]; then
printf '%s\n' "$INKSCAPE"
return 0
fi
if command -v inkscape >/dev/null 2>&1; then
command -v inkscape
return 0
fi
if [[ -x /Applications/Inkscape.app/Contents/MacOS/inkscape ]]; then
printf '%s\n' /Applications/Inkscape.app/Contents/MacOS/inkscape
return 0
fi
return 1
}
export_png() {
local inkscape="$1"
local width="$2"
local height="$3"
"$inkscape" "$SOURCE" --export-type=png --export-background-opacity=1 \
--export-width="$width" --export-height="$height" \
--export-filename=".wordpress-org/banner-${width}x${height}.png"
return 0
}
main() {
cd "$(git rev-parse --show-toplevel)"
[[ -f "$SOURCE" ]] || die "missing $SOURCE"
local inkscape
inkscape="$(find_inkscape)" || die "Inkscape not found; set INKSCAPE=/path/to/inkscape"
if command -v fc-list >/dev/null 2>&1; then
local families
families="$(fc-list : family)"
grep -qi '^Zilla Slab' <<<"$families" || die "the Zilla Slab font is not installed"
fi
"$inkscape" "$SOURCE" --export-text-to-path --export-plain-svg \
--export-filename=admin/images/banner.svg
export_png "$inkscape" 1544 500
export_png "$inkscape" 772 250
ls -l admin/images/banner.svg .wordpress-org/banner-1544x500.png .wordpress-org/banner-772x250.png
return 0
}
main "$@"
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# Build the release zips of the plugin from a Git ref.
#
# Two builds of the same version, both with one {slug}/ folder inside (the
# slug is the main file's name, scripts/lib/plugin.sh):
# {slug}-X.Y.Z.zip
# GitHub release asset: the files in Git, less .distignore.
# wordpress-org-{slug}-X.Y.Z.zip
# WordPress.org build: the same, less the files in .distignore-wporg and
# the GitHub updater header lines. Its name does not start with the
# slug, so no updater picks it even if it is attached to a GitHub
# release by mistake (it takes the first asset whose name starts with
# the plugin slug). Never attach it to a GitHub release.
# SHA256SUMS
#
# Files come from the Git ref (git archive), never from the working tree, so
# uncommitted changes and untracked files are never included.
#
# Usage: scripts/build-release.sh [--ref REF] [--out DIR] [--quiet]
# --ref REF Commit, branch or tag to build (default: HEAD).
# --out DIR Output folder (default: dist/ in the repository, gitignored).
# --quiet Print only the paths of the zips.
#
# Needs git, rsync, zip, unzip and shasum or sha256sum.
set -euo pipefail
umask 022
export TZ=UTC
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
readonly WPORG_IGNORE=".distignore-wporg"
# Header lines read only by GitHub updaters (ours and Git Updater); left out of the WordPress.org build.
readonly WPORG_STRIP_HEADERS='GitHub Plugin URI|Primary Branch|Release Asset'
TMP_DIR=""
SLUG=""
MAIN_FILE=""
die() {
local message="$1"
printf 'build-release: %s\n' "$message" >&2
exit 1
}
usage() {
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
cleanup() {
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
need() {
local tool="$1"
command -v "$tool" >/dev/null 2>&1 || die "needs $tool"
return 0
}
# Version: header of the main file at a commit.
version_at() {
local sha="$1"
git show "$sha:$MAIN_FILE" | sed -n 's/^[[:space:]*]*Version:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -n 1
return 0
}
sha256_of() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" | cut -d' ' -f1
else
sha256sum "$file" | cut -d' ' -f1
fi
return 0
}
# Zip <folder>/<SLUG> as <zip>, with the slug folder at the top. Fixed times,
# modes (umask) and entry order make the same ref give the same zip anywhere.
make_zip() {
local folder="$1"
local zip_path="$2"
local partial="$zip_path.partial"
rm -f "$zip_path" "$partial"
(cd "$folder" && find "$SLUG" -exec touch -h -t 202001010000 {} + && find "$SLUG" | LC_ALL=C sort | zip -qX "$partial" -@)
mv "$partial" "$zip_path"
return 0
}
# Remove the GitHub updater header lines from the main file of a build.
strip_updater_headers() {
local main="$1"
local stripped="$main.tmp"
grep -Ev "^[[:space:]*]*($WPORG_STRIP_HEADERS):" "$main" >"$stripped"
mv "$stripped" "$main"
return 0
}
main() {
local ref="HEAD"
local out=""
local quiet=0
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$2"
shift
;;
--out)
[ $# -ge 2 ] || die "--out needs a value"
out="$2"
shift
;;
--quiet) quiet=1 ;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
local tool
for tool in git rsync zip unzip; do
need "$tool"
done
local root sha version
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
sha="$(git rev-parse --verify --quiet "$ref^{commit}")" || die "not a commit: $ref"
plugin_identity "$sha" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
MAIN_FILE="$PLUGIN_MAIN_FILE"
version="$(version_at "$sha")"
[ -n "$version" ] || die "no Version: header in $MAIN_FILE at $ref"
if [ "$ref" = "HEAD" ] && [ -n "$(git status --porcelain --untracked-files=no)" ] && [ "$quiet" -eq 0 ]; then
printf 'Note: uncommitted changes are not in the build (it is made from HEAD).\n'
fi
[ -n "$out" ] || out="$root/dist"
mkdir -p "$out"
out="$(cd "$out" && pwd)"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-build.XXXXXX")"
mkdir -p "$TMP_DIR/src" "$TMP_DIR/github/$SLUG" "$TMP_DIR/wporg/$SLUG"
git archive --format=tar "$sha" | tar -x -C "$TMP_DIR/src"
# .distignore lists itself and is export-ignored, so read it from Git.
git show "$sha:.distignore" >"$TMP_DIR/distignore" || die ".distignore missing at $ref"
rsync -a --exclude-from="$TMP_DIR/distignore" "$TMP_DIR/src/" "$TMP_DIR/github/$SLUG/"
[ -f "$TMP_DIR/github/$SLUG/$MAIN_FILE" ] || die "$MAIN_FILE is not in the build; check .distignore"
if git cat-file -e "$sha:$WPORG_IGNORE" 2>/dev/null; then
git show "$sha:$WPORG_IGNORE" >"$TMP_DIR/wporg-ignore"
else
: >"$TMP_DIR/wporg-ignore"
fi
rsync -a --exclude-from="$TMP_DIR/wporg-ignore" "$TMP_DIR/github/$SLUG/" "$TMP_DIR/wporg/$SLUG/"
strip_updater_headers "$TMP_DIR/wporg/$SLUG/$MAIN_FILE"
local github_zip="$out/$SLUG-$version.zip"
local wporg_zip="$out/wordpress-org-$SLUG-$version.zip"
make_zip "$TMP_DIR/github" "$github_zip"
make_zip "$TMP_DIR/wporg" "$wporg_zip"
{
printf '%s %s\n' "$(sha256_of "$github_zip")" "$(basename "$github_zip")"
printf '%s %s\n' "$(sha256_of "$wporg_zip")" "$(basename "$wporg_zip")"
} >"$out/SHA256SUMS"
if [ "$quiet" -eq 1 ]; then
printf '%s\n%s\n' "$github_zip" "$wporg_zip"
return 0
fi
printf '%s %s from %s (%s)\n' "$PLUGIN_NAME" "$version" "$ref" "${sha:0:12}"
printf ' GitHub release: %s (%s files)\n' "$github_zip" "$(unzip -Z1 "$github_zip" | grep -cv '/$')"
printf ' WordPress.org: %s (%s files)\n' "$wporg_zip" "$(unzip -Z1 "$wporg_zip" | grep -cv '/$')"
printf ' Checksums: %s\n' "$out/SHA256SUMS"
printf 'Check them with: scripts/preflight-release.sh --ref %s\n' "$ref"
return 0
}
main "$@"
+55
View File
@@ -0,0 +1,55 @@
# Core files: the same in every plugin made from the starter, apart from the
# names (STANDARDS.md → Structure). scripts/sync-core.sh copies them from
# the starter with the plugin's names, and --check lists any that differ.
# Paths use the starter's names; a trailing / means every file below.
# Change a core file in the starter first, then sync each plugin.
# Bootstrap, feature registry, base feature and settings store.
includes/class-wpstarter.php
includes/class-wpstarter-feature.php
includes/class-wpstarter-settings.php
# Shared GitHub updater: only the newest copy on a site runs.
includes/github-updater/
# Admin screen, Read Me tab and the Plugins screen notes for replaced plugins.
admin/settings.php
admin/data/readme.php
admin/includes/class-admin-manager.php
admin/includes/class-settings-manager.php
admin/includes/class-readme-manager.php
admin/includes/class-replaced-plugins.php
admin/css/wpstarter-admin.css
admin/js/wpstarter-admin.js
# Build, check, release and preview scripts.
scripts/build-banner.sh
scripts/build-release.sh
scripts/core-files.txt
scripts/lib/plugin.sh
scripts/lint.sh
scripts/phpstan-bootstrap.php
scripts/plugin-check.sh
scripts/preflight-release.sh
scripts/preview-site.sh
scripts/rename-plugin.sh
scripts/replaced-plugins.php
scripts/smoke-test.sh
scripts/sync-core.sh
# CI and tool configuration.
.github/
.distignore
.gitattributes
.gitignore
.gitleaks.toml
composer.json
phpcs.xml.dist
phpstan.neon.dist
# Shared docs.
STANDARDS.md
DEVELOPMENT.md
RELEASING.md
CONTRIBUTING.md
SECURITY.md
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# The PLUGIN_* variables are read by the scripts that source this file.
# shellcheck disable=SC2034
# Who the plugin is, read from its main file, so the scripts in scripts/ work
# unchanged in every plugin made from the starter. Source it, then call
# plugin_identity [REF] (default HEAD). It sets:
#
# PLUGIN_MAIN_FILE the PHP file at the top of the repository with a
# "Plugin Name:" header, for example wp-plugin-starter-template.php
# PLUGIN_SLUG its name without .php: the plugin folder and text domain
# PLUGIN_NAME the Plugin Name header, for example WP Plugin Starter
# PLUGIN_PACKAGE the @package tag, which is also the class prefix
# (WPStarter: WPStarter_Settings, WPStarter::features())
# PLUGIN_CONST the constant prefix, from define('<PREFIX>_VERSION', ...)
# (WPSTARTER)
# PLUGIN_PREFIX the option, hook and transient prefix: PLUGIN_CONST in
# lower case (wpstarter)
# PLUGIN_CSS the CSS class and data attribute prefix, from the admin
# screen's "wrap <css>-wrap" class (wps: .wps-card)
# PLUGIN_REPO the GitHub Plugin URI header, owner/repo (may be empty)
#
# Files come from the Git ref, never the working tree, like the release build.
PLUGIN_MAIN_FILE=""
PLUGIN_SLUG=""
PLUGIN_NAME=""
PLUGIN_PACKAGE=""
PLUGIN_CONST=""
PLUGIN_PREFIX=""
PLUGIN_CSS=""
PLUGIN_REPO=""
# Value of a "Key: value" header line in the text, case-insensitive.
plugin_header_field() {
local text="$1"
local key="$2"
printf '%s\n' "$text" | awk -v k="$key" '
BEGIN { k = tolower(k) }
{
line = $0
sub(/^[ \t*]*/, "", line)
i = index(line, ":")
if (i > 0 && tolower(substr(line, 1, i - 1)) == k) {
v = substr(line, i + 1)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
print v
exit
}
}'
return 0
}
plugin_identity() {
local ref="${1:-HEAD}"
local file text found=""
while IFS= read -r file; do
case "$file" in
*.php) ;;
*) continue ;;
esac
# WordPress reads the header from the first 8 KB of the file.
text="$(git show "$ref:$file" 2>/dev/null || true)"
if [ -n "$(plugin_header_field "${text:0:8192}" "Plugin Name")" ]; then
[ -z "$found" ] || {
printf 'plugin: more than one main file at %s: %s and %s\n' "$ref" "$found" "$file" >&2
return 1
}
found="$file"
fi
done < <(git ls-tree --name-only "$ref" 2>/dev/null)
if [ -z "$found" ]; then
printf 'plugin: no PHP file with a Plugin Name: header at the top of %s\n' "$ref" >&2
return 1
fi
text="$(git show "$ref:$found")"
PLUGIN_MAIN_FILE="$found"
PLUGIN_SLUG="${found%.php}"
PLUGIN_NAME="$(plugin_header_field "${text:0:8192}" "Plugin Name")"
PLUGIN_PACKAGE="$(printf '%s\n' "$text" | sed -nE '/^[[:space:]*]*@package[[:space:]]+[A-Za-z0-9_]+/{s/^[[:space:]*]*@package[[:space:]]+([A-Za-z0-9_]+).*/\1/p;q;}')"
PLUGIN_CONST="$(printf '%s\n' "$text" | sed -nE "/define\([[:space:]]*['\"][A-Z0-9_]+_VERSION['\"]/{s/.*define\([[:space:]]*['\"]([A-Z0-9_]+)_VERSION['\"].*/\1/p;q;}")"
if [ -z "$PLUGIN_PACKAGE" ]; then
printf 'plugin: %s has no @package tag (the class prefix)\n' "$found" >&2
return 1
fi
if [ -z "$PLUGIN_CONST" ]; then
printf "plugin: %s does not define a <PREFIX>_VERSION constant\n" "$found" >&2
return 1
fi
PLUGIN_PREFIX="$(printf '%s' "$PLUGIN_CONST" | tr '[:upper:]' '[:lower:]')"
PLUGIN_REPO="$(plugin_header_field "${text:0:8192}" "GitHub Plugin URI")"
PLUGIN_CSS="$(git show "$ref:admin/includes/class-admin-manager.php" 2>/dev/null | sed -nE 's/.*class="wrap ([a-z0-9]+)-wrap.*/\1/p' | head -n 1)"
if [ -z "$PLUGIN_CSS" ]; then
printf 'plugin: no "wrap <css>-wrap" class in admin/includes/class-admin-manager.php at %s\n' "$ref" >&2
return 1
fi
return 0
}
# Copy the PLUGIN_* names into FROM_* (from) or TO_* (to), for plugin_map.
plugin_names_as() {
local side="$1"
local name
for name in SLUG NAME PACKAGE CONST PREFIX CSS REPO; do
eval "export ${side}_${name}=\"\$PLUGIN_${name}\""
done
return 0
}
# Rewrite text on stdin from one plugin's names (FROM_*) to another's (TO_*):
# GitHub repository, name, class prefix, constant prefix, slug, prefix and
# CSS prefix, in that order. When the source plugin's slug is also its prefix
# (myplugin), the slug is only the quoted word ('myplugin': text domain,
# admin page) and the main file name; everywhere else it is the prefix.
plugin_map() {
perl -pe '
BEGIN { %e = map { $_ => $ENV{$_} // "" } grep { /^(FROM|TO)_/ } keys %ENV; }
s{\Q$e{FROM_REPO}\E}{$e{TO_REPO}}g if $e{FROM_REPO} ne "" && $e{TO_REPO} ne "";
s{\Q$e{FROM_NAME}\E}{$e{TO_NAME}}g;
s{\Q$e{FROM_PACKAGE}\E}{$e{TO_PACKAGE}}g;
s{\Q$e{FROM_CONST}\E}{$e{TO_CONST}}g;
if ($e{FROM_SLUG} eq $e{FROM_PREFIX}) {
s{(["\x27])\Q$e{FROM_SLUG}\E\1}{${1}$e{TO_SLUG}${1}}g;
s{\b\Q$e{FROM_SLUG}\E\.php\b}{$e{TO_SLUG}.php}g;
} else {
s{\Q$e{FROM_SLUG}\E}{$e{TO_SLUG}}g;
}
s{\Q$e{FROM_PREFIX}\E}{$e{TO_PREFIX}}g;
s{\b\Q$e{FROM_CSS}\E(?=[-_A-Z])}{$e{TO_CSS}}g;
'
return 0
}
# Value of the environment variable <PLUGIN_CONST>_<NAME>, or the default.
# For example plugin_env PREVIEW_SITE reads WPSTARTER_PREVIEW_SITE.
plugin_env() {
local name="${PLUGIN_CONST}_$1"
local default="${2:-}"
printf '%s' "${!name:-$default}"
return 0
}
# Paths the WordPress.org build leaves out (.distignore-wporg at the ref),
# one per line, without the leading slash: the GitHub updater.
plugin_wporg_only() {
local ref="${1:-HEAD}"
git show "$ref:.distignore-wporg" 2>/dev/null | sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' -e 's|^/||' -e 's|/$||'
return 0
}
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Code checks for the plugin, the same ones CI runs on every pull request
# (.github/workflows/ci.yml). Changes nothing.
#
# Usage: scripts/lint.sh [CHECK]...
# php PHP syntax of every PHP file, with the local php.
# js JavaScript syntax of every JS file (node --check).
# shell ShellCheck on every shell script.
# workflows actionlint on .github/workflows (skipped if not installed).
# phpcs WordPress Coding Standards, security sniffs and PHP 7.4
# compatibility (phpcs.xml.dist).
# phpstan Static analysis (phpstan.neon.dist): no new findings beyond
# phpstan-baseline.neon.
# With no CHECK, runs them all. phpcs and phpstan need `composer install`.
#
# Exit status: 0 when every check passes, 1 when any fails, 2 on bad usage.
set -euo pipefail
readonly ALL_CHECKS="php js shell workflows phpcs phpstan"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly ROOT
FAILED=""
usage() {
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
say() {
local message="$1"
printf '\n== %s\n' "$message"
return 0
}
need_vendor() {
local tool="$1"
if [ ! -x "$ROOT/vendor/bin/$tool" ]; then
printf 'lint: vendor/bin/%s is missing; run: composer install\n' "$tool" >&2
return 1
fi
return 0
}
check_php() {
local file output status=0
while IFS= read -r -d '' file; do
if ! output="$(php -l "$file" 2>&1)"; then
printf '%s\n' "$output"
status=1
fi
done < <(git -C "$ROOT" ls-files -z '*.php')
return "$status"
}
check_js() {
local file status=0
while IFS= read -r -d '' file; do
node --check "$file" || status=1
done < <(git -C "$ROOT" ls-files -z '*.js')
return "$status"
}
check_shell() {
local files=()
local file
while IFS= read -r -d '' file; do
files+=("$file")
done < <(git -C "$ROOT" ls-files -z '*.sh')
[ "${#files[@]}" -eq 0 ] && return 0
# -x follows the scripts' `# shellcheck source=` lines (scripts/lib/).
shellcheck -x --severity=style "${files[@]}"
return $?
}
check_workflows() {
if [ ! -d "$ROOT/.github/workflows" ]; then
return 0
fi
if ! command -v actionlint >/dev/null 2>&1; then
printf 'actionlint is not installed; skipped (CI runs it)\n'
return 0
fi
actionlint
return $?
}
check_phpcs() {
need_vendor phpcs || return 1
vendor/bin/phpcs -q --report=full
return $?
}
check_phpstan() {
need_vendor phpstan || return 1
vendor/bin/phpstan analyse --memory-limit=4G --no-progress
return $?
}
main() {
local checks="$*"
local check
if [ -z "$checks" ]; then
checks="$ALL_CHECKS"
fi
for check in $checks; do
case " $ALL_CHECKS " in
*" $check "*) ;;
*)
usage >&2
return 2
;;
esac
done
cd "$ROOT"
for check in $checks; do
say "$check"
if "check_$check"; then
printf 'ok\n'
else
FAILED="$FAILED $check"
fi
done
if [ -n "$FAILED" ]; then
printf '\nlint: failed:%s\n' "$FAILED" >&2
return 1
fi
printf '\nlint: all passed (%s)\n' "$checks"
return 0
}
main "$@"
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* Constants for PHPStan (phpstan.neon.dist). WordPress's functions and
* classes come from szepeviktor/phpstan-wordpress; this adds the constants
* WordPress defines while it loads (wp-settings.php, wp-config.php) and
* those wp-plugin-starter-template.php defines.
*
* @package WPStarter
*/
define('WPINC', 'wp-includes');
define('COOKIEPATH', '/');
define('SITECOOKIEPATH', '/');
define('DB_NAME', 'wordpress');
define('WPSTARTER_VERSION', '0.0.0');
define('WPSTARTER_FILE', dirname(__DIR__) . '/wp-plugin-starter-template.php');
define('WPSTARTER_DIR', dirname(__DIR__) . '/');
define('WPSTARTER_URL', 'https://example.com/wp-content/plugins/wp-plugin-starter-template/');
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# Run Plugin Check (the WordPress.org review tool) on the release zips, in a
# disposable WordPress in Docker. Nothing is kept: the containers, volume and
# network are removed when it ends, and no other site is touched.
#
# Usage: scripts/plugin-check.sh [--ref REF] [--zip FILE]... [--keep-output DIR]
# --ref REF Build both zips from REF (default: HEAD) and check them.
# --zip FILE Check this zip instead (repeatable); it must hold one
# {slug}/ folder.
# --keep-output DIR Save each full report as JSON in DIR.
#
# Exit status: 0 when no zip has Plugin Check errors. Warnings are listed;
# review them before a WordPress.org submission. Updater findings in the
# GitHub zip's updater files (.distignore-wporg) are expected and not counted.
# Needs Docker and internet access (WordPress and Plugin Check are downloaded).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
# Set in main() from the plugin's main file.
SLUG=""
UPDATER_FILES=""
CLI_IMAGE=""
DB_IMAGE=""
readonly DB_PASSWORD="plugincheck"
NAME=""
TMP_DIR=""
STARTED=0
die() {
local message="$1"
printf 'plugin-check: %s\n' "$message" >&2
exit 2
}
usage() {
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
cleanup() {
if [ "$STARTED" -eq 1 ]; then
local attempt id
# wp-cli containers still stopping (after Ctrl-C) keep the volume and
# network in use, so remove them first and retry.
for attempt in 1 2 3; do
docker ps -aq --filter "volume=$NAME-wp" | while IFS= read -r id; do
docker rm -f "$id" >/dev/null 2>&1 || true
done
docker rm -f "$NAME-db" >/dev/null 2>&1 || true
docker volume rm "$NAME-wp" >/dev/null 2>&1 || true
docker network rm "$NAME" >/dev/null 2>&1 || true
if ! docker volume inspect "$NAME-wp" >/dev/null 2>&1 && ! docker network inspect "$NAME" >/dev/null 2>&1; then
break
fi
[ "$attempt" -eq 3 ] && printf 'plugin-check: could not remove %s-wp or network %s\n' "$NAME" "$NAME" >&2
sleep 2
done
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
# wp-cli in the disposable site; zips are mounted at /zips. The image's
# 128 MB PHP memory limit is too small to unpack WordPress.
wp_cli() {
docker run --rm --network "$NAME" -v "$NAME-wp:/var/www/html" -v "$TMP_DIR/zips:/zips:ro" \
--user 33:33 "$CLI_IMAGE" php -d memory_limit=1G /usr/local/bin/wp "$@"
return $?
}
start_site() {
printf 'Starting a disposable WordPress (%s)...\n' "$NAME"
STARTED=1
docker network create "$NAME" >/dev/null
docker volume create "$NAME-wp" >/dev/null
docker run -d --name "$NAME-db" --network "$NAME" \
-e MARIADB_ROOT_PASSWORD="$DB_PASSWORD" -e MARIADB_DATABASE=wordpress "$DB_IMAGE" >/dev/null
# The volume starts owned by root; let www-data (33) write to it.
docker run --rm -v "$NAME-wp:/var/www/html" --user 0:0 "$CLI_IMAGE" chown 33:33 /var/www/html
# Ping over TCP: the image's first, socket-only server answers on the
# socket before the real server is listening.
local waited=0
until docker exec "$NAME-db" mariadb-admin ping -h127.0.0.1 -uroot -p"$DB_PASSWORD" --silent >/dev/null 2>&1; do
[ "$waited" -lt 90 ] || die "the database did not start"
sleep 2
waited=$((waited + 2))
done
wp_cli core download --quiet
wp_cli config create --dbname=wordpress --dbuser=root --dbpass="$DB_PASSWORD" --dbhost="$NAME-db" --skip-check --quiet
wp_cli core install --url=http://localhost --title=PluginCheck --admin_user=admin --admin_password="$DB_PASSWORD" \
--admin_email=admin@example.com --skip-email --quiet
wp_cli plugin install plugin-check --activate --quiet
printf 'WordPress %s, Plugin Check %s\n' "$(wp_cli core version)" "$(wp_cli plugin get plugin-check --field=version)"
return 0
}
# Check one zip; returns 1 when Plugin Check reports errors.
check_zip() {
local zip_name="$1"
local keep="$2"
local report errors warnings
printf '\n== %s ==\n' "$zip_name"
if ! wp_cli plugin install "/zips/$zip_name" --force --quiet; then
printf 'Could not install %s.\n' "$zip_name"
return 1
fi
report="$(wp_cli plugin check "$SLUG" --format=json 2>&1 || true)"
if [ -n "$keep" ]; then
printf '%s\n' "$report" >"$keep/${zip_name%.zip}-plugin-check.json"
fi
if ! printf '%s\n' "$report" | grep -Eq '^(FILE: |Success: )'; then
# Neither findings nor the success line: Plugin Check did not run.
printf 'Plugin Check did not run:\n%s\n' "$report"
wp_cli plugin delete "$SLUG" --quiet || true
return 1
fi
errors="$( (printf '%s\n' "$report" | grep -o '"type":"ERROR"' || true) | wc -l | tr -d ' ')"
warnings="$( (printf '%s\n' "$report" | grep -o '"type":"WARNING"' || true) | wc -l | tr -d ' ')"
# The GitHub zip carries Updates from GitHub on purpose; Plugin Check
# reports it as an updater. Those findings are expected there (and only
# in those files); in the WordPress.org zip they stay errors. So are
# prefix warnings there: the shared updater's names (wpallstars_) are the
# same in every plugin, so that one copy can stand in for the others.
local expected=0
local expected_warnings=0
local counts
case "$zip_name" in
"$SLUG"-*)
# The file list goes through the environment: awk -v cannot hold newlines.
counts="$(printf '%s\n' "$report" | FILES="$UPDATER_FILES" awk '
BEGIN { nfiles = split(ENVIRON["FILES"], list, "\n") }
# A listed file, or a file inside a listed folder.
function updater(path, i) {
for (i = 1; i <= nfiles; i++) {
if (list[i] != "" && (path == list[i] || index(path, list[i] "/") == 1)) { return 1 }
}
return 0
}
/^FILE: / { current = substr($0, 7); next }
/^\[/ && updater(current) {
n = split($0, items, "},{")
for (i = 1; i <= n; i++) {
if (items[i] ~ /"type":"ERROR"/ && items[i] ~ /"code":"(plugin_updater_detected|update_modification_detected|PluginCheck\.CodeAnalysis\.Offloading\.OffloadedContent)"/) { count++ }
if (items[i] ~ /"type":"WARNING"/ && items[i] ~ /"code":"WordPress\.NamingConventions\.PrefixAllGlobals\./) { prefix++ }
}
}
END { print count + 0, prefix + 0 }')"
expected="${counts% *}"
expected_warnings="${counts#* }"
;;
esac
if [ "$expected" -gt 0 ] || [ "$expected_warnings" -gt 0 ]; then
printf '%s updater error(s) and %s prefix warning(s) in %s are expected in the GitHub zip.\n' "$expected" "$expected_warnings" "$(printf '%s' "$UPDATER_FILES" | tr '\n' ' ')"
errors=$((errors - expected))
warnings=$((warnings - expected_warnings))
fi
# Readable summary: one line per finding (type, code, file:line).
printf '%s\n' "$report" | awk '
/^FILE: / { file = substr($0, 7); next }
/^\[/ {
n = split($0, items, "},{")
for (i = 1; i <= n; i++) {
t = items[i]; c = items[i]; l = items[i]
sub(/.*"type":"/, "", t); sub(/".*/, "", t)
sub(/.*"code":"/, "", c); sub(/".*/, "", c)
sub(/.*"line":/, "", l); sub(/[^0-9].*/, "", l)
printf " %-7s %s %s:%s\n", t, c, file, l
}
}'
printf '%s error(s), %s warning(s)\n' "$errors" "$warnings"
wp_cli plugin delete "$SLUG" --quiet || true
[ "$errors" -eq 0 ] || return 1
return 0
}
main() {
local ref="HEAD"
local keep=""
local zips=""
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$2"
shift
;;
--zip)
[ $# -ge 2 ] || die "--zip needs a file"
[ -f "$2" ] || die "no such zip: $2"
zips="$zips
$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
shift
;;
--keep-output)
[ $# -ge 2 ] || die "--keep-output needs a folder"
mkdir -p "$2"
keep="$(cd "$2" && pwd)"
shift
;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
command -v docker >/dev/null 2>&1 || die "needs Docker"
docker info >/dev/null 2>&1 || die "Docker is not running"
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
plugin_identity "$ref" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
UPDATER_FILES="$(plugin_wporg_only "$ref")"
CLI_IMAGE="$(plugin_env CLI_IMAGE wordpress:cli-php8.3)"
DB_IMAGE="$(plugin_env DB_IMAGE mariadb:10.6)"
NAME="$SLUG-plugincheck-$$"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-plugincheck.XXXXXX")"
mkdir -p "$TMP_DIR/zips"
chmod 755 "$TMP_DIR" "$TMP_DIR/zips"
local zip_path
if [ -z "$zips" ]; then
"$root/scripts/build-release.sh" --ref "$ref" --out "$TMP_DIR/zips" --quiet >/dev/null || die "build failed"
else
while IFS= read -r zip_path; do
[ -n "$zip_path" ] && cp "$zip_path" "$TMP_DIR/zips/"
done <<EOF
$zips
EOF
fi
chmod 644 "$TMP_DIR"/zips/*.zip
start_site
local failed=0 zip_name
for zip_path in "$TMP_DIR"/zips/*.zip; do
zip_name="$(basename "$zip_path")"
check_zip "$zip_name" "$keep" || failed=1
done
printf '\n'
if [ "$failed" -eq 1 ]; then
printf 'Plugin Check found errors.\n'
return 1
fi
printf 'Plugin Check: no errors.\n'
return 0
}
main "$@"
+586
View File
@@ -0,0 +1,586 @@
#!/usr/bin/env bash
# Check a version of the plugin before it is released on GitHub or
# submitted to WordPress.org. Changes nothing: it builds both zips into a
# temporary folder (scripts/build-release.sh) and reads them and the Git ref.
#
# Errors stop a release. Warnings need a look and a decision; most matter only
# for WordPress.org. Notes are for information.
#
# Usage: scripts/preflight-release.sh [--ref REF] [--offline] [--strict] [--no-docker]
# --ref REF Commit, branch or tag to check (default: HEAD).
# --offline Skip checks that ask WordPress.org (latest WordPress version,
# slug, contributor profiles).
# --strict Fail on warnings too (use before a WordPress.org submission).
# --no-docker Lint PHP with the local php instead of PHP 7.4 in Docker.
#
# Exit status: 0 when there are no errors (and, with --strict, no warnings).
# Plugin Check runs separately: scripts/plugin-check.sh.
set -euo pipefail
readonly UPDATER_HEADERS='GitHub Plugin URI|Primary Branch|Release Asset'
# Development files that must never be in a release zip (paths inside the slug folder).
readonly DEV_FILES='^[^/]+/(\.git|\.agents|\.wordpress-org|\.distignore|\.distignore-wporg|\.gitattributes|\.gitignore|\.woodpecker\.yml|\.github|\.editorconfig|\.gitleaks\.toml|\.aidevops\.json|composer\.(json|lock)|phpcs\.xml(\.dist)?|phpstan(-baseline|-plugin)?\.neon(\.dist)?|vendor|AGENTS\.md|CONTRIBUTING\.md|DEVELOPMENT\.md|LAUNCH\.md|SECURITY\.md|STANDARDS\.md|RELEASING\.md|ROADMAP\.md|STABILITY\.md|TESTING\.md|scripts|dist|node_modules|reference-plugins|project-documents)(/|$)|(^|/)(\.DS_Store|__MACOSX|Thumbs\.db)(/|$)|\.(bak|log|orig|swp)$'
readonly README_MAX_BYTES=10240
readonly SHORT_DESC_MAX=150
readonly MAX_TAGS=5
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
# Set from the main file at the ref (plugin_identity).
SLUG=""
MAIN_FILE=""
VERSION_CONSTANT=""
# Paths only in the GitHub build (.distignore-wporg): the GitHub updater.
UPDATER_FILES=""
ERRORS=0
WARNINGS=0
TMP_DIR=""
OFFLINE=0
USE_DOCKER=1
CHANGELOG_TXT=""
die() {
local message="$1"
printf 'preflight: %s\n' "$message" >&2
exit 2
}
usage() {
sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
cleanup() {
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
ok() {
local message="$1"
printf ' ok %s\n' "$message"
return 0
}
err() {
local message="$1"
printf ' ERROR %s\n' "$message"
ERRORS=$((ERRORS + 1))
return 0
}
warn() {
local message="$1"
printf ' warn %s\n' "$message"
WARNINGS=$((WARNINGS + 1))
return 0
}
note() {
local message="$1"
printf ' note %s\n' "$message"
return 0
}
section() {
local title="$1"
printf '\n%s\n' "$title"
return 0
}
# Value of "Key: value" in a header (plugin file comment or readme), case-insensitive.
field() {
local text="$1"
local key="$2"
printf '%s\n' "$text" | awk -v k="$key" '
BEGIN { k = tolower(k) }
{
line = $0
sub(/^[ \t*]*/, "", line)
i = index(line, ":")
if (i > 0 && tolower(substr(line, 1, i - 1)) == k) {
v = substr(line, i + 1)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
print v
exit
}
}'
return 0
}
# 1 if version a < b (numeric parts), else 0.
version_lt() {
local a="$1"
local b="$2"
awk -v a="$a" -v b="$b" 'BEGIN {
na = split(a, x, "."); nb = split(b, y, "."); n = (na > nb) ? na : nb
for (i = 1; i <= n; i++) { if ((x[i] + 0) < (y[i] + 0)) { print 1; exit } if ((x[i] + 0) > (y[i] + 0)) { print 0; exit } }
print 0 }'
return 0
}
# HTTP status of a URL (000 when offline or unreachable).
http_status() {
local url="$1"
local code
# curl prints 000 itself when it cannot connect, and exits non-zero.
code="$(curl -sL -o /dev/null -m 20 -w '%{http_code}' "$url" || true)"
printf '%s' "${code:-000}"
return 0
}
check_versions() {
local plugin_header="$1"
local readme="$2"
local main_php="$3"
section "Versions and headers"
local version constant stable
version="$(field "$plugin_header" "Version")"
constant="$(printf '%s\n' "$main_php" | sed -nE "/define\([[:space:]]*['\"]${VERSION_CONSTANT}['\"]/{s/.*,[[:space:]]*['\"]([^'\"]+)['\"].*/\1/p;q;}")"
stable="$(field "$readme" "Stable tag")"
if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
ok "Version: $version (numbers only, so GitHub updates and WordPress.org treat it as stable)"
else
err "Version: '$version' is not X.Y.Z; pre-release versions on main are offered to sites still using Git Updater"
fi
if [ "$constant" = "$version" ]; then ok "version constant matches ($constant)"; else err "version constant is '$constant', Version: is '$version'"; fi
if [ "$stable" = "$version" ]; then ok "readme.txt Stable tag matches"; else err "readme.txt Stable tag is '$stable', Version: is '$version'"; fi
if [ "$(printf '%s' "$stable" | tr '[:upper:]' '[:lower:]')" = "trunk" ]; then err "Stable tag: trunk is not allowed for new plugins"; fi
local key header_value readme_value
for key in "Requires at least" "Requires PHP"; do
header_value="$(field "$plugin_header" "$key")"
readme_value="$(field "$readme" "$key")"
if [ -z "$header_value" ]; then
err "$MAIN_FILE has no '$key' header (WordPress reads it from the plugin file)"
elif [ -n "$readme_value" ] && [ "$header_value" != "$readme_value" ]; then
err "$key: $header_value in $MAIN_FILE, $readme_value in readme.txt"
else
ok "$key: $header_value"
fi
done
if printf '%s\n' "$main_php" | grep -Eiq '^[[:space:]*]*Update URI:'; then
err "Update URI header found: WordPress.org rejects it and Plugin Check reports an updater"
else
ok "no Update URI header"
fi
local domain license plugin_uri author_uri
domain="$(field "$plugin_header" "Text Domain")"
if [ "$domain" = "$SLUG" ]; then ok "Text Domain: $domain"; else err "Text Domain is '$domain'; it must be the slug ($SLUG) for language packs"; fi
license="$(field "$plugin_header" "License")"
if printf '%s' "$license" | grep -Eiq 'GPL'; then ok "License: $license"; else err "License '$license' is not GPL-compatible as written"; fi
plugin_uri="$(field "$plugin_header" "Plugin URI")"
author_uri="$(field "$plugin_header" "Author URI")"
if [ -n "$plugin_uri" ] && [ "$plugin_uri" = "$author_uri" ]; then
warn "Plugin URI and Author URI are the same ($plugin_uri); WordPress.org asks for a Plugin URI unique to the plugin, or none"
fi
VERSION="$version"
return 0
}
# Changelog text (a readme section or changelog.txt): an entry for the version,
# and no Unreleased section left.
check_changelog() {
local label="$1"
local text="$2"
local version="$3"
if printf '%s\n' "$text" | grep -Eq "^= *v?$version *="; then
ok "$label changelog has $version"
else
warn "$label changelog has no '= $version =' entry"
fi
if printf '%s\n' "$text" | grep -Eiq '^= *unreleased *='; then
warn "$label changelog still has an Unreleased section; name it $version when releasing"
fi
return 0
}
check_readme() {
local readme="$1"
local plugin_header="$2"
local version="$3"
section "readme.txt"
local bytes
bytes="$(printf '%s' "$readme" | wc -c | tr -d ' ')"
if [ "$bytes" -le "$README_MAX_BYTES" ]; then
ok "size ${bytes} bytes ($((README_MAX_BYTES - bytes)) left)"
else
warn "size ${bytes} bytes; WordPress.org says over 10 KB may cause errors (keep the current changelog, move older entries to changelog.txt, trim the description)"
fi
local name readme_name
name="$(field "$plugin_header" "Plugin Name")"
readme_name="$(printf '%s\n' "$readme" | sed -n '1s/^===[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*===.*/\1/p')"
if [ "$readme_name" = "$name" ]; then ok "name matches the plugin header ($name)"; else warn "readme name '$readme_name' differs from Plugin Name '$name'"; fi
local short
short="$(printf '%s\n' "$readme" | awk 'NR == 1 { next } !h && /^[ \t]*$/ { h = 1; next } h && /^==/ { exit } h && !/^[ \t]*$/ { print; exit }')"
if [ -z "$short" ]; then
err "no short description (the line after the header)"
elif [ "${#short}" -le "$SHORT_DESC_MAX" ]; then
ok "short description ${#short} characters"
else
warn "short description ${#short} characters; WordPress.org cuts it at $SHORT_DESC_MAX"
fi
local tags tag_count
tags="$(field "$readme" "Tags")"
tag_count="$(printf '%s\n' "$tags" | awk -F',' '{ print ($0 == "") ? 0 : NF }')"
if [ "$tag_count" -ge 1 ] && [ "$tag_count" -le "$MAX_TAGS" ]; then ok "$tag_count tags"; else warn "$tag_count tags; WordPress.org shows at most $MAX_TAGS"; fi
local tested
tested="$(field "$readme" "Tested up to")"
if printf '%s' "$tested" | grep -Eq '^[0-9]+\.[0-9]+$'; then
ok "Tested up to: $tested"
else
err "Tested up to '$tested' should be a major version such as 6.8"
fi
if [ "$OFFLINE" -eq 0 ]; then
local latest latest_major
# The first offer is the latest release; later ones are older branches.
latest="$(curl -s -m 20 'https://api.wordpress.org/core/version-check/1.7/' | grep -o '"current":"[0-9.]*"' | head -n 1 | cut -d'"' -f4 || true)"
latest_major="$(printf '%s' "$latest" | cut -d. -f1-2)"
if [ -z "$latest_major" ]; then
note "could not read the latest WordPress version"
elif [ "$(version_lt "$tested" "$latest_major")" = "1" ]; then
warn "Tested up to $tested, latest WordPress is $latest; test and raise it"
else
ok "Tested up to is the latest WordPress ($latest)"
fi
fi
check_changelog "readme.txt" "$(printf '%s\n' "$readme" | awk '/^== *[Cc]hangelog *==/ { c = 1; next } c && /^== / { exit } c')" "$version"
if [ -n "$CHANGELOG_TXT" ]; then
check_changelog "changelog.txt" "$CHANGELOG_TXT" "$version"
fi
if printf '%s\n' "$readme" | grep -Eiq '^== *upgrade notice *=='; then
if printf '%s\n' "$readme" | awk '/^== *[Uu]pgrade [Nn]otice *==/ { u = 1; next } u && /^== / { exit } u' | grep -Eq "^= *v?$version *="; then
ok "upgrade notice has $version"
else
note "no upgrade notice for $version (optional)"
fi
fi
return 0
}
check_wporg() {
local readme="$1"
local plugin_header="$2"
section "WordPress.org listing"
local name derived
name="$(field "$plugin_header" "Plugin Name")"
derived="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')"
if [ "$derived" = "$SLUG" ]; then
ok "the slug WordPress.org makes from '$name' is $SLUG"
else
warn "WordPress.org makes the slug from Plugin Name: '$name' becomes '$derived', not '$SLUG'. Ask for '$SLUG' in the submission notes (it can change only before approval), or the text domain will not match"
fi
if printf '%s' "$name" | grep -Eiq '^(wordpress|wp|woocommerce|woo|gutenberg)([^a-z]|$)'; then
warn "Plugin Name starts with a trademark ('$name'); WordPress.org does not allow that"
fi
if [ "$OFFLINE" -eq 1 ]; then
note "offline: slug and contributor profiles not checked"
return 0
fi
local info
info="$(curl -s -m 20 "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Bslug%5D=$SLUG&request%5Bfields%5D%5Bsections%5D=0" || true)"
if printf '%s' "$info" | grep -q '"error"'; then
note "slug $SLUG is not listed on WordPress.org yet"
elif [ -n "$info" ]; then
note "slug $SLUG is already listed on WordPress.org; check it is this plugin"
fi
local contributors user status
contributors="$(field "$readme" "Contributors")"
if [ -z "$contributors" ]; then
err "readme.txt has no Contributors"
fi
for user in $(printf '%s' "$contributors" | tr ',' ' '); do
status="$(http_status "https://profiles.wordpress.org/$user/")"
case "$status" in
200) ok "contributor $user has a WordPress.org profile" ;;
000) note "could not check contributor $user" ;;
*) warn "contributor '$user' has no WordPress.org profile (HTTP $status); Contributors must be WordPress.org usernames, case-sensitive" ;;
esac
done
return 0
}
# Lint PHP (PHP 7.4 in Docker when possible) and JS in an unpacked build.
check_syntax() {
local dir="$1"
local label="$2"
local out php_label
local expected
expected="$(find "$dir" -name '*.php' | wc -l | tr -d ' ')"
if [ "$USE_DOCKER" -eq 1 ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
local image="php:7.4-cli"
if docker image inspect wordpress:php7.4-apache >/dev/null 2>&1; then image="wordpress:php7.4-apache"; fi
out="$(docker run --rm -v "$dir:/src:ro" "$image" sh -c 'find /src -name "*.php" -exec php -l {} \;' 2>&1 || true)"
php_label="PHP 7.4 syntax ($image)"
elif command -v php >/dev/null 2>&1; then
out="$(find "$dir" -name '*.php' -exec php -l {} \; 2>&1 || true)"
php_label="PHP syntax ($(php -r 'echo PHP_VERSION;'); not 7.4)"
else
out=""
php_label=""
warn "$label: no PHP to lint with"
fi
if [ -n "$php_label" ]; then
# Count the files php -l passed, so a lint that never ran cannot pass.
local passed problems
passed="$(printf '%s\n' "$out" | grep -c '^No syntax errors' || true)"
problems="$(printf '%s\n' "$out" | grep -v '^No syntax errors' | grep -v '^[[:space:]]*$' || true)"
if [ -z "$problems" ] && [ "$passed" -eq "$expected" ] && [ "$expected" -gt 0 ]; then
ok "$label: $php_label, $passed files"
elif [ -z "$problems" ]; then
err "$label: PHP lint checked $passed of $expected files"
else
err "$label: $php_label errors:"
printf '%s\n' "$problems" | sed 's/^/ /'
fi
fi
if command -v node >/dev/null 2>&1; then
out="$(find "$dir" -name '*.js' -exec node --check {} \; 2>&1 || true)"
if [ -z "$out" ]; then ok "$label: JS syntax"; else err "$label: JS syntax errors:"; printf '%s\n' "$out" | sed 's/^/ /'; fi
else
warn "$label: node not found, JS not checked"
fi
return 0
}
# Shared checks for a release zip; prints its unpacked folder in UNPACKED.
check_zip() {
local zip_path="$1"
local label="$2"
local dir="$TMP_DIR/unpacked-$label"
local list tops dev size
list="$(unzip -Z1 "$zip_path")"
tops="$(printf '%s\n' "$list" | cut -d/ -f1 | sort -u | tr '\n' ' ')"
if [ "$tops" = "$SLUG " ]; then ok "$label: one $SLUG/ folder"; else err "$label: top level is '$tops', must be only $SLUG/"; fi
if printf '%s\n' "$list" | grep -qx "$SLUG/$MAIN_FILE"; then ok "$label: $SLUG/$MAIN_FILE present"; else err "$label: $SLUG/$MAIN_FILE missing"; fi
dev="$(printf '%s\n' "$list" | grep -E "$DEV_FILES" || true)"
if [ -z "$dev" ]; then ok "$label: no development files"; else err "$label: development files: $(printf '%s' "$dev" | tr '\n' ' ')"; fi
size="$(wc -c <"$zip_path" | tr -d ' ')"
note "$label: $(basename "$zip_path"), $((size / 1024)) KB, $(printf '%s\n' "$list" | grep -cv '/$') files"
mkdir -p "$dir"
unzip -q "$zip_path" -d "$dir"
check_syntax "$dir" "$label"
UNPACKED="$dir/$SLUG"
return 0
}
check_builds() {
local github_zip="$1"
local wporg_zip="$2"
local version="$3"
section "Release zips"
if [ "$(basename "$github_zip")" = "$SLUG-$version.zip" ]; then
ok "GitHub asset name $SLUG-$version.zip (sites pick assets starting with $SLUG)"
else
err "GitHub asset is $(basename "$github_zip"), expected $SLUG-$version.zip"
fi
case "$(basename "$wporg_zip")" in
"$SLUG"*) err "WordPress.org zip name starts with $SLUG; sites could install it from GitHub" ;;
*) ok "WordPress.org zip name does not start with $SLUG" ;;
esac
UNPACKED=""
check_zip "$github_zip" "github"
local github_dir="$UNPACKED" path
if [ -z "$UPDATER_FILES" ]; then
note "no .distignore-wporg: both builds hold the same files"
fi
for path in $UPDATER_FILES; do
if [ -e "$github_dir/$path" ]; then ok "github: has $path"; else err "github: $path missing (listed in .distignore-wporg)"; fi
done
if grep -Eq "^[[:space:]*]*GitHub Plugin URI:" "$github_dir/$MAIN_FILE"; then ok "github: has the GitHub Plugin URI header"; else err "github: no GitHub Plugin URI header, sites cannot update it from GitHub"; fi
check_zip "$wporg_zip" "wporg"
local wporg_dir="$UNPACKED"
for path in $UPDATER_FILES; do
if [ -e "$wporg_dir/$path" ]; then err "wporg: $path must not be in the WordPress.org build"; else ok "wporg: no $path"; fi
done
if grep -Eq "^[[:space:]*]*($UPDATER_HEADERS):" "$wporg_dir/$MAIN_FILE"; then err "wporg: GitHub updater header lines still in $MAIN_FILE"; else ok "wporg: no GitHub updater header lines"; fi
local hits
hits="$(grep -rEl --include='*.php' --include='*.js' 'gu_override_dot_org|api\.github\.com/repos|Plugin_Upgrader|Theme_Upgrader|site_transient_update_plugins|auto_update_(plugin|theme)' "$wporg_dir" 2>/dev/null | sed "s|^$wporg_dir/||" || true)"
if [ -z "$hits" ]; then
ok "wporg: no code that installs or updates plugins from elsewhere"
else
warn "wporg: check these install or update code (guideline 8 allows only WordPress.org sources): $(printf '%s' "$hits" | tr '\n' ' ')"
fi
section "Remote assets and services (WordPress.org build)"
hits="$(grep -rEn "(wp_(enqueue|register)_(script|style)|<script|<link)[^;]*['\"](https?:)?//" "$wporg_dir" --include='*.php' 2>/dev/null | sed "s|^$wporg_dir/||" | cut -c1-160 || true)"
if [ -z "$hits" ]; then ok "no scripts or styles loaded from other sites"; else warn "scripts or styles from other sites (guideline 8: ship them in the plugin unless they are part of a service):"; printf '%s\n' "$hits" | sed 's/^/ /'; fi
local readme_text hosts host missing=""
readme_text="$(cat "$wporg_dir/readme.txt")"
hosts="$(grep -rEoh 'https?://[A-Za-z0-9.-]+\.[a-z]{2,}' "$wporg_dir/includes" "$wporg_dir/blocks" 2>/dev/null | sed -E 's|https?://||; s|^www\.||' | sort -u || true)"
for host in $hosts; do
case "$host" in
w3.org | gnu.org | wordpress.org | *.wordpress.org | w.org | *.w.org | wp.org | *.wp.org | example.com | *.example.com) continue ;;
esac
if ! printf '%s' "$readme_text" | grep -Fqi "$host"; then missing="$missing $host"; fi
done
if [ -z "$missing" ]; then
ok "every host in includes/ and blocks/ is named in readme.txt"
else
note "hosts in code not named in readme.txt (fine if they are only links; services the plugin contacts need an External services entry):$missing"
fi
# Checks for parts only some plugins have; each runs when its files exist.
# A plugin's own checks: scripts/preflight-plugin.sh defines plugin_preflight,
# which gets the unpacked GitHub build and can use section, ok, warn and err.
if [ -f "$SCRIPT_DIR/preflight-plugin.sh" ]; then
# shellcheck source=/dev/null
. "$SCRIPT_DIR/preflight-plugin.sh"
plugin_preflight "$github_dir"
fi
if [ -f "$SCRIPT_DIR/replaced-plugins.php" ]; then
check_replaced_count "$github_dir"
fi
return 0
}
# README.md's "<Plugin Name> replaces **N plugins**" line matches the
# 'replaces' entries in the code (scripts/replaced-plugins.php).
check_replaced_count() {
local dir="$1"
section "Replaced plugins"
if ! command -v php >/dev/null 2>&1; then
warn "php not found, the replaced plugins count in README.md was not checked"
return 0
fi
local out
if out="$(php "$SCRIPT_DIR/replaced-plugins.php" --check "$dir" 2>&1)"; then
ok "$out"
else
err "$out"
fi
return 0
}
# Core files match the starter's (scripts/sync-core.sh --check), when a
# checkout of the starter is at hand. A warning, not an error: the starter's
# checkout may be behind, and only a person can tell which side is right.
check_core_files() {
section "Core files"
local out status=0
out="$(bash "$SCRIPT_DIR/sync-core.sh" --check 2>&1)" || status=$?
case "$status" in
0) ok "$(printf '%s\n' "$out" | tail -n 1)" ;;
1) warn "$(printf '%s\n' "$out" | sed -n '2,$p' | tr '\n' ' ')" ;;
*) note "not compared: ${out#sync-core: }" ;;
esac
return 0
}
check_git() {
local ref="$1"
local sha="$2"
local version="$3"
section "Git"
local tag_sha
if tag_sha="$(git rev-parse --verify --quiet "refs/tags/v$version^{commit}")"; then
if [ "$tag_sha" = "$sha" ]; then ok "tag v$version is this commit"; else warn "tag v$version exists on another commit (${tag_sha:0:12}); bump the version"; fi
else
note "no tag v$version yet"
fi
local main_sha
if main_sha="$(git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}")"; then
if git merge-base --is-ancestor "$sha" "$main_sha"; then ok "$ref is on origin/main"; else note "$ref is not on origin/main yet; release only from main"; fi
fi
if [ "$ref" = "HEAD" ] && [ -n "$(git status --porcelain --untracked-files=no)" ]; then
note "uncommitted changes are not checked (the build comes from HEAD)"
fi
return 0
}
main() {
local ref="HEAD"
local strict=0
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$2"
shift
;;
--offline) OFFLINE=1 ;;
--strict) strict=1 ;;
--no-docker) USE_DOCKER=0 ;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
local root sha
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
sha="$(git rev-parse --verify --quiet "$ref^{commit}")" || die "not a commit: $ref"
plugin_identity "$sha" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
MAIN_FILE="$PLUGIN_MAIN_FILE"
VERSION_CONSTANT="${PLUGIN_CONST}_VERSION"
UPDATER_FILES="$(plugin_wporg_only "$sha")"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-preflight.XXXXXX")"
local main_php readme plugin_header
main_php="$(git show "$sha:$MAIN_FILE")"
readme="$(git show "$sha:readme.txt")" || die "readme.txt missing at $ref"
if git cat-file -e "$sha:changelog.txt" 2>/dev/null; then
CHANGELOG_TXT="$(git show "$sha:changelog.txt")"
fi
plugin_header="$(printf '%s\n' "$main_php" | sed -n '1,/\*\//p')"
printf '%s preflight: %s (%s)\n' "$PLUGIN_NAME" "$ref" "${sha:0:12}"
VERSION=""
check_versions "$plugin_header" "$readme" "$main_php"
check_readme "$readme" "$plugin_header" "$VERSION"
check_wporg "$readme" "$plugin_header"
local zips github_zip wporg_zip
zips="$("$root/scripts/build-release.sh" --ref "$sha" --out "$TMP_DIR/dist" --quiet)" || die "build failed"
github_zip="$(printf '%s\n' "$zips" | sed -n 1p)"
wporg_zip="$(printf '%s\n' "$zips" | sed -n 2p)"
check_builds "$github_zip" "$wporg_zip" "$VERSION"
if [ -f "$SCRIPT_DIR/sync-core.sh" ]; then
check_core_files
fi
check_git "$ref" "$sha" "$VERSION"
printf '\n%s error(s), %s warning(s).\n' "$ERRORS" "$WARNINGS"
if [ "$ERRORS" -gt 0 ]; then
printf 'Not ready: fix the errors.\n'
return 1
fi
if [ "$strict" -eq 1 ] && [ "$WARNINGS" -gt 0 ]; then
printf 'Not ready (--strict): resolve or accept the warnings.\n'
return 1
fi
printf 'Next: scripts/plugin-check.sh (Plugin Check on both zips).\n'
return 0
}
main "$@"
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env bash
# Copy a combined preview of the plugin to the shared local test site:
# origin/main plus every open pull request from this repository, merged in
# PR order. Branches that conflict are left out and reported, except when
# the only conflicts are in the changelogs (DOC_FILES): every PR adds lines
# at the top of the same lists, so after each merge to main the open PRs
# conflict there. For the preview those files keep both sides' lines (git's
# union merge) and the PR is included, with a note; real merges are not
# affected.
#
# Nothing is checked out, committed to a branch or pushed: merges are worked
# out with `git merge-tree` and exported with `git archive`, so any worktree
# can run it, and every run includes everyone's pushed work. The copy holds
# what a release build contains (.distignore applied). A checkout whose copy
# of this script differs from origin/main's runs origin/main's, so an old
# worktree cannot leave PRs out (<PREFIX>_PREVIEW_OWN=1 runs its own; the
# prefix is the plugin's constant prefix, such as WPSTARTER).
#
# Usage: scripts/preview-site.sh [--dry-run] [<site>]
# <site> WordPress folder of the test site (the one with wp-load.php).
# Remembered for every worktree of this clone after first use;
# <PREFIX>_PREVIEW_SITE overrides it.
# --dry-run Report what would be included; copy nothing.
#
# Needs git 2.38+ (merge-tree --write-tree), gh (signed in) and rsync.
set -euo pipefail
readonly LOCK_WAIT_SECONDS=180
readonly LOCK_STALE_MINUTES=10
# Files whose conflicts do not keep a PR out of the preview.
readonly DOC_FILES="changelog.txt readme.txt README.md"
LOCK_DIR=""
TMP_DIR=""
UNION_ATTRIBUTES=""
# Set in main() from the plugin's main file (scripts/lib/plugin.sh).
STAMP_NAME=""
# Results, set by the functions below (Bash 3.2 has no namerefs).
SITE=""
MAIN_SHA=""
PREVIEW_COMMIT=""
INCLUDED=""
SKIPPED=""
die() {
local message="$1"
printf 'preview-site: %s\n' "$message" >&2
exit 1
}
usage() {
sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
# Source scripts/lib/plugin.sh: the copy beside this script, or origin/main's
# when this runs as origin/main's copy from a temporary file and an older
# checkout started it (run_latest) without the library beside it.
load_plugin_lib() {
local dir lib
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -r "$dir/lib/plugin.sh" ]; then
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$dir/lib/plugin.sh"
return 0
fi
lib="$(git show origin/main:scripts/lib/plugin.sh)" || die "scripts/lib/plugin.sh not found beside the script or on origin/main"
eval "$lib"
return 0
}
cleanup() {
if [ -n "$LOCK_DIR" ] && [ -f "$LOCK_DIR/pid" ] && [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$$" ]; then
rm -rf "$LOCK_DIR"
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
# One run at a time across all worktrees; a lock left by a dead run is taken over.
acquire_lock() {
local lock="$1"
local waited=0
local owner=""
while ! mkdir "$lock" 2>/dev/null; do
owner="$(cat "$lock/pid" 2>/dev/null || true)"
if { [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; } || [ -n "$(find "$lock" -maxdepth 0 -mmin +"$LOCK_STALE_MINUTES" 2>/dev/null)" ]; then
rm -rf "$lock"
continue
fi
if [ "$waited" -ge "$LOCK_WAIT_SECONDS" ]; then
die "another preview run is still going (lock: $lock)"
fi
[ "$waited" -eq 0 ] && printf 'Waiting for another preview run to finish...\n'
sleep 3
waited=$((waited + 3))
done
LOCK_DIR="$lock"
printf '%s\n' "$$" >"$lock/pid"
return 0
}
# owner/repo of the GitHub remote "origin".
origin_slug() {
local url
url="$(git remote get-url origin)"
url="${url%.git}"
case "$url" in
*github.com[:/]*) printf '%s\n' "${url#*github.com[:/]}" ;;
*) die "origin is not a GitHub remote: $url" ;;
esac
return 0
}
# Sets SITE: the argument, then the environment, then the one remembered for
# this clone. A new argument is remembered unless this is a dry run.
resolve_site() {
local site_arg="$1"
local saved="$2"
local remember="$3"
SITE="$(plugin_env PREVIEW_SITE)"
[ -n "$site_arg" ] && SITE="$site_arg"
if [ -z "$SITE" ] && [ -f "$saved" ]; then
SITE="$(cat "$saved")"
fi
[ -n "$SITE" ] || die "which site? Run: scripts/preview-site.sh \"<WordPress folder>\" (remembered after that)"
SITE="${SITE%/}"
if [ ! -f "$SITE/wp-load.php" ] || [ ! -d "$SITE/wp-content/plugins" ]; then
die "not a WordPress folder: $SITE"
fi
if [ -n "$site_arg" ] && [ "$remember" -eq 1 ]; then
printf '%s\n' "$SITE" >"$saved"
fi
return 0
}
# Open same-repository PRs as "number<TAB>branch<TAB>draft|ready<TAB>title", oldest first.
open_prs() {
local slug
slug="$(origin_slug)"
gh pr list --repo "$slug" --state open --limit 100 \
--json number,headRefName,isDraft,isCrossRepository,title \
--jq 'sort_by(.number) | .[] | select(.isCrossRepository | not) | [.number, .headRefName, (if .isDraft then "draft" else "ready" end), .title] | @tsv' ||
return 1
return 0
}
# Merge one PR branch onto PREVIEW_COMMIT, or record why it is left out.
merge_pr() {
local num="$1"
local ref="$2"
local state="$3"
local title="$4"
local sha out tree files
local note=""
local rc=0
if ! sha="$(git rev-parse --verify --quiet "refs/remotes/origin/$ref^{commit}")"; then
SKIPPED="${SKIPPED} #$num $ref: branch not found on origin
"
return 0
fi
if git merge-base --is-ancestor "$sha" "$PREVIEW_COMMIT"; then
INCLUDED="${INCLUDED} #$num $ref ${sha:0:7} ($state, nothing new) $title
"
return 0
fi
out="$(git merge-tree --write-tree --name-only --no-messages "$PREVIEW_COMMIT" "$sha")" || rc=$?
if [ "$rc" -eq 1 ]; then
files="$(printf '%s\n' "$out" | sed 1d | sort -u | tr '\n' ' ')"
files="${files% }"
if only_doc_files "$files"; then
rc=0
out="$(git -c core.attributesFile="$UNION_ATTRIBUTES" merge-tree --write-tree --name-only --no-messages "$PREVIEW_COMMIT" "$sha")" || rc=$?
[ "$rc" -eq 0 ] && note="; both sides' lines kept in $files"
fi
fi
if [ "$rc" -eq 0 ]; then
tree="${out%%$'\n'*}"
PREVIEW_COMMIT="$(git -c user.name=preview -c user.email=preview@localhost commit-tree "$tree" -p "$PREVIEW_COMMIT" -p "$sha" -m "preview: merge #$num $ref")"
INCLUDED="${INCLUDED} #$num $ref ${sha:0:7} ($state$note) $title
"
elif [ "$rc" -eq 1 ]; then
SKIPPED="${SKIPPED} #$num $ref ${sha:0:7}: conflicts with main or an earlier PR in $files
"
else
die "git merge-tree failed for #$num $ref"
fi
return 0
}
# Whether every file in the space-separated list is one of DOC_FILES.
only_doc_files() {
local files="$1"
local file
[ -n "$files" ] || return 1
for file in $files; do
case " $DOC_FILES " in
*" $file "*) ;;
*) return 1 ;;
esac
done
return 0
}
# Write the attributes file that makes DOC_FILES merge as a union.
write_union_attributes() {
local file
: >"$UNION_ATTRIBUTES"
for file in $DOC_FILES; do
printf '/%s merge=union\n' "$file" >>"$UNION_ATTRIBUTES"
done
return 0
}
# Sets MAIN_SHA, PREVIEW_COMMIT, INCLUDED and SKIPPED.
build_preview() {
local prs num ref state title
prs="$(open_prs)" ||
die "could not list open pull requests with gh; nothing copied (a copy without them would hide work in progress)"
MAIN_SHA="$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
PREVIEW_COMMIT="$MAIN_SHA"
while IFS=$'\t' read -r num ref state title; do
[ -n "$num" ] || continue
merge_pr "$num" "$ref" "$state" "$title"
done <<EOF
$prs
EOF
return 0
}
# A note when the branch this runs from is not in the preview as it is here.
branch_note() {
local current="$1"
local merged changed
if [ -z "$current" ] || [ "$current" = "main" ]; then
return 0
fi
# Nothing to add: its work is already in the preview, through main (after
# a squash merge its commits are not ancestors of main) or its open PR.
# Changelog lines can sit in another order there (other PRs' lines are
# kept beside them), so differences only in DOC_FILES do not count.
if merged="$(git -c core.attributesFile="$UNION_ATTRIBUTES" merge-tree --write-tree --no-messages "$PREVIEW_COMMIT" HEAD 2>/dev/null)"; then
changed="$(git diff --name-only "$PREVIEW_COMMIT" "$merged" | tr '\n' ' ')"
changed="${changed% }"
if [ -z "$changed" ] || only_doc_files "$changed"; then
return 0
fi
fi
if ! printf '%s' "$INCLUDED" | grep -q " $current "; then
printf 'Your branch %s is not in the preview: push it and open a draft PR, or see skipped above.\n' "$current"
elif [ "$(git rev-parse HEAD)" != "$(git rev-parse --verify --quiet "refs/remotes/origin/$current" || true)" ]; then
printf 'Your branch %s is in the preview as pushed; local commits not pushed are not.\n' "$current"
fi
return 0
}
# The stamp text; it ends with a newline.
report() {
local root="$1"
local current="$2"
printf 'Combined preview: origin/main plus open pull requests, made by scripts/preview-site.sh.\n'
printf 'time: %s\n' "$(date '+%Y-%m-%d %H:%M %Z')"
printf 'run from: %s (%s)\n' "$root" "${current:-detached}"
printf 'main: %s\n' "$(git log -1 --format='%h %s' "$MAIN_SHA")"
printf 'preview commit: %s (local only)\n' "${PREVIEW_COMMIT:0:12}"
printf 'included:\n%s' "${INCLUDED:- none
}"
printf 'skipped:\n%s' "${SKIPPED:- none
}"
return 0
}
# Copy PREVIEW_COMMIT to the site as a release build would contain it.
copy_to_site() {
local destination="$SITE/wp-content/plugins/$PLUGIN_SLUG"
[ -n "$PLUGIN_SLUG" ] || die "no plugin slug"
mkdir "$TMP_DIR/plugin"
git archive --format=tar "$PREVIEW_COMMIT" | tar -x -C "$TMP_DIR/plugin"
git show "$PREVIEW_COMMIT:.distignore" >"$TMP_DIR/distignore"
mkdir -p "$destination"
rsync -a --delete --delete-excluded --exclude-from="$TMP_DIR/distignore" "$TMP_DIR/plugin/" "$destination/"
return 0
}
# Run origin/main's copy of this script when this checkout's differs, so a
# worktree made before a fix to the script cannot leave open PRs out of the
# shared preview. <PREFIX>_PREVIEW_OWN=1 runs this copy instead (to try a
# change to the script itself). The copy goes in a temporary folder with
# origin/main's scripts/lib/plugin.sh beside it.
run_latest() {
local dry_run="$1"
local site_arg="$2"
local root="$3"
local latest_var="${PLUGIN_CONST}_PREVIEW_LATEST"
local latest
latest="$(plugin_env PREVIEW_LATEST)"
if [ -n "$latest" ]; then
# Already main's copy: bash has the file open and the library is
# loaded, so the folder can go now.
rm -rf "$latest"
return 0
fi
if [ "$(plugin_env PREVIEW_OWN)" = "1" ]; then
return 0
fi
git fetch --quiet --prune origin || return 0
latest="$(mktemp -d "${TMPDIR:-/tmp}/$PLUGIN_SLUG-preview-latest.XXXXXX")"
mkdir -p "$latest/lib"
if ! git show origin/main:scripts/preview-site.sh >"$latest/preview-site.sh" 2>/dev/null ||
! git show origin/main:scripts/lib/plugin.sh >"$latest/lib/plugin.sh" 2>/dev/null ||
{ cmp -s "$latest/preview-site.sh" "$root/scripts/preview-site.sh" && cmp -s "$latest/lib/plugin.sh" "$root/scripts/lib/plugin.sh"; }; then
rm -rf "$latest"
return 0
fi
printf 'preview-site: this checkout'\''s copy of the script differs from origin/main; running origin/main'\''s.\n' >&2
if [ "$dry_run" -eq 1 ]; then
exec env "$latest_var=$latest" bash "$latest/preview-site.sh" --dry-run ${site_arg:+"$site_arg"}
fi
exec env "$latest_var=$latest" bash "$latest/preview-site.sh" ${site_arg:+"$site_arg"}
}
main() {
local dry_run=0
local site_arg=""
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--dry-run) dry_run=1 ;;
-h | --help)
usage
return 0
;;
-*) die "unknown option: $arg" ;;
*) site_arg="$arg" ;;
esac
shift
done
local root common current text note
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
common="$(git rev-parse --path-format=absolute --git-common-dir)"
cd "$root"
load_plugin_lib
plugin_identity HEAD || die "cannot tell which plugin this is"
STAMP_NAME="$PLUGIN_SLUG-synced-from.txt"
run_latest "$dry_run" "$site_arg" "$root"
resolve_site "$site_arg" "$common/$PLUGIN_SLUG-preview-site" "$((1 - dry_run))"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$PLUGIN_SLUG-preview.XXXXXX")"
UNION_ATTRIBUTES="$TMP_DIR/union-attributes"
write_union_attributes
if [ "$dry_run" -eq 0 ]; then
acquire_lock "$common/$PLUGIN_SLUG-preview.lock"
fi
git fetch --quiet --prune origin
build_preview
current="$(git symbolic-ref --short -q HEAD || true)"
text="$(report "$root" "$current")"
note="$(branch_note "$current")"
if [ "$dry_run" -eq 0 ]; then
copy_to_site
printf '%s\n' "$text" >"$SITE/wp-content/$STAMP_NAME"
fi
printf '%s\n' "$text"
[ -n "$note" ] && printf '\n%s\n' "$note"
if [ "$dry_run" -eq 1 ]; then
printf '\nDry run: nothing copied to %s\n' "$SITE"
else
printf '\nCopied to %s/wp-content/plugins/%s\n' "$SITE" "$PLUGIN_SLUG"
fi
return 0
}
main "$@"
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Give this plugin new names: run it once in a fresh copy of the starter to
# start a new plugin. Renames every tracked file's contents and paths, then
# leaves the changes for you to review and commit.
#
# Usage: scripts/rename-plugin.sh --slug SLUG --name NAME --prefix Prefix
# [--const PREFIX] [--css CSS] [--repo OWNER/REPO]
# --slug Plugin folder, main file and text domain (my-plugin).
# --name Plugin Name (My Plugin).
# --prefix Class prefix (MyPlugin: MyPlugin_Settings).
# --const Constant prefix (default: --prefix in capitals, MYPLUGIN). In
# lower case it is the option, hook and function prefix (myplugin).
# --css CSS class and data attribute prefix (default: the lower-case
# prefix; a short one such as mp keeps the markup readable).
# --repo GitHub repository, owner/repo (default: wpallstars/SLUG).
#
# Needs a clean working tree. Then update README.md, readme.txt,
# changelog.txt, AGENTS.md and the banner (STANDARDS.md and DEVELOPMENT.md say how).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
die() {
local message="$1"
printf 'rename-plugin: %s\n' "$message" >&2
exit 2
}
TMP_FILE=""
cleanup() {
[ -z "$TMP_FILE" ] || rm -f "$TMP_FILE"
return 0
}
usage() {
sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
need() {
local flag="$1"
local value="$2"
local pattern="$3"
[ -n "$value" ] || die "$flag is needed (see --help)"
printf '%s' "$value" | grep -Eq "$pattern" || die "$flag '$value' must match $pattern"
return 0
}
main() {
local slug="" name="" prefix="" const="" css="" repo=""
while [ $# -gt 0 ]; do
local arg="$1"
local value="${2:-}"
case "$arg" in
--slug | --name | --prefix | --const | --css | --repo)
[ $# -ge 2 ] || die "$arg needs a value"
case "$arg" in
--slug) slug="$value" ;;
--name) name="$value" ;;
--prefix) prefix="$value" ;;
--const) const="$value" ;;
--css) css="$value" ;;
--repo) repo="$value" ;;
esac
shift
;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
[ -n "$const" ] || const="$(printf '%s' "$prefix" | tr '[:lower:]' '[:upper:]')"
[ -n "$css" ] || css="$(printf '%s' "$const" | tr '[:upper:]' '[:lower:]')"
[ -n "$repo" ] || repo="wpallstars/$slug"
need --slug "$slug" '^[a-z][a-z0-9-]*[a-z0-9]$'
need --name "$name" '^[^/\\]+$'
need --prefix "$prefix" '^[A-Z][A-Za-z0-9]*$'
need --const "$const" '^[A-Z][A-Z0-9]*$'
need --css "$css" '^[a-z][a-z0-9]*$'
need --repo "$repo" '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
[ -z "$(git status --porcelain)" ] || die "commit or put away your changes first"
plugin_identity HEAD || die "cannot tell which plugin this is"
plugin_names_as FROM
export TO_SLUG="$slug" TO_NAME="$name" TO_PACKAGE="$prefix" TO_CONST="$const"
export TO_PREFIX
TO_PREFIX="$(printf '%s' "$const" | tr '[:upper:]' '[:lower:]')"
export TO_CSS="$css" TO_REPO="$repo"
[ "$TO_SLUG" != "$FROM_SLUG" ] || die "the slug is already $slug"
printf 'Renaming %s to %s: %s / %s / %s / %s / %s / %s\n' "$FROM_NAME" "$name" "$slug" "$TO_PREFIX" "$prefix" "$const" "$css" "$repo"
local file target changed=0 moved=0
trap cleanup EXIT
TMP_FILE="$(mktemp "${TMPDIR:-/tmp}/rename-plugin.XXXXXX")"
local tmp="$TMP_FILE"
while IFS= read -r file; do
[ -f "$file" ] || continue
# Text files only (pictures and other binaries keep their bytes).
if grep -Iq . "$file"; then
plugin_map <"$file" >"$tmp"
if ! cmp -s "$tmp" "$file"; then
cat "$tmp" >"$file"
changed=$((changed + 1))
fi
fi
target="$(printf '%s' "$file" | plugin_map)"
if [ "$target" != "$file" ]; then
mkdir -p "$(dirname "$target")"
git mv "$file" "$target"
moved=$((moved + 1))
fi
done < <(git ls-files)
printf '%d files changed, %d renamed. Run composer update --lock (the package name changed), review with git diff and git status, then commit.\n' "$changed" "$moved"
return 0
}
main "$@"
+422
View File
@@ -0,0 +1,422 @@
<?php
/**
* Count the plugins WP Plugin Starter replaces and keep README.md's count right.
*
* Reads every 'replaces' entry in includes/ (the same lists the Plugins screen
* uses), without loading WordPress: array literals, class constants
* (self::X, Class::X), variables assigned in the same file and + unions.
* Anything else stops the script, so it never counts wrong in silence.
*
* Usage: php scripts/replaced-plugins.php [--check | --write] [DIR]
* (none) List the replaced plugins (slug and name) and the count.
* --check Exit 1 when README.md's "WP Plugin Starter replaces **N plugins**"
* line has another count. Offline; scripts/preflight-release.sh
* runs it on the release build.
* --write Rewrite that line with the count and download sizes: each
* plugin's zip from WordPress.org (or its GitHub release, see
* GITHUB_SOURCES) against WP Plugin Starter's GitHub release zip,
* built from HEAD with scripts/build-release.sh. Needs network.
* DIR Plugin folder to read (default: this checkout).
*
* Exit status: 0 when fine, 1 when --check finds a different count, 2 on errors.
*/
// Replaced plugins that are not on WordPress.org but have GitHub releases.
const GITHUB_SOURCES = array('git-updater' => 'afragen/git-updater');
const README_LINE = '/^WP Plugin Starter replaces \*\*(\d+) plugins\*\*.*$/m';
/**
* Stop with a message.
*
* @param string $message What went wrong.
*/
function fail($message) {
fwrite(STDERR, "replaced-plugins: $message\n");
exit(2);
}
/**
* Tokens of a PHP file without whitespace and comments, as [id, text, line].
*
* @param string $file Path.
* @return array
*/
function tokens($file) {
$out = array();
$line = 1;
foreach (token_get_all(file_get_contents($file)) as $token) {
if (is_array($token)) {
$line = $token[2];
if (in_array($token[0], array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) {
continue;
}
$out[] = array($token[0], $token[1], $line);
} else {
$out[] = array($token, $token, $line);
}
}
return $out;
}
/**
* Small evaluator for the expressions used in 'replaces'.
*/
final class Replaces_Reader {
/** @var array "Class::NAME" => array(file, tokens, start, class). */
private $consts = array();
/** @var array Slug => name. */
public $plugins = array();
/** @var array Slug => list of "file:line". */
public $where = array();
/** @var array Current file's tokens. */
private $t;
/** @var int Current position. */
private $i;
/** @var string Current file. */
private $file;
/** @var string Current class. */
private $class;
/** @var array Variable name => position of its value in $t. */
private $vars;
/**
* Read every PHP file under $dir/includes.
*
* @param string $dir Plugin folder.
*/
public function read($dir) {
$files = array();
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir . '/includes', FilesystemIterator::SKIP_DOTS));
foreach ($it as $f) {
if ('php' === $f->getExtension()) {
$files[] = $f->getPathname();
}
}
sort($files);
$parsed = array();
foreach ($files as $file) {
$parsed[$file] = tokens($file);
$this->collect_consts($file, $parsed[$file]);
}
foreach ($parsed as $file => $t) {
$this->collect_replaces($file, $t, $dir);
}
ksort($this->plugins);
}
private function collect_consts($file, array $t) {
$class = '';
foreach ($t as $i => $tok) {
if (T_CLASS === $tok[0] && isset($t[$i + 1]) && T_STRING === $t[$i + 1][0]) {
$class = $t[$i + 1][1];
}
if (T_CONST === $tok[0] && '' !== $class && isset($t[$i + 2]) && '=' === $t[$i + 2][0]) {
$this->consts[$class . '::' . $t[$i + 1][1]] = array($file, $t, $i + 3, $class);
}
}
}
private function collect_replaces($file, array $t, $dir) {
$this->file = $file;
$this->t = $t;
$this->class = '';
$this->vars = array();
$n = count($t);
for ($i = 0; $i < $n; $i++) {
$tok = $t[$i];
if (T_CLASS === $tok[0] && isset($t[$i + 1]) && T_STRING === $t[$i + 1][0]) {
$this->class = $t[$i + 1][1];
} elseif (T_VARIABLE === $tok[0] && isset($t[$i + 1]) && '=' === $t[$i + 1][0]) {
$this->vars[$tok[1]] = $i + 2;
} elseif (T_CONSTANT_ENCAPSED_STRING === $tok[0] && "'replaces'" === $tok[1] && isset($t[$i + 1]) && T_DOUBLE_ARROW === $t[$i + 1][0]) {
$this->i = $i + 2;
$value = $this->expr();
if (!is_array($value)) {
$this->error('replaces is not an array');
}
foreach ($value as $slug => $name) {
if (!is_string($name)) {
$this->error("replaces[$slug] is not a name");
}
if (!isset($this->plugins[$slug])) {
$this->plugins[$slug] = $name;
}
$this->where[$slug][] = substr($file, strlen($dir) + 1) . ':' . $tok[2];
}
}
}
}
private function error($message) {
$line = isset($this->t[$this->i]) ? $this->t[$this->i][2] : '?';
fail("{$this->file}:$line: $message");
}
private function peek() {
return isset($this->t[$this->i]) ? $this->t[$this->i] : array(null, '', 0);
}
private function expect($id) {
$tok = $this->peek();
if ($tok[0] !== $id) {
$this->error("expected $id, found '{$tok[1]}'");
}
$this->i++;
}
/** expr := term ('+' term)* */
private function expr() {
$value = $this->term();
while ('+' === $this->peek()[0]) {
$this->i++;
$right = $this->term();
if (!is_array($value) || !is_array($right)) {
$this->error('+ on something that is not an array');
}
$value = $value + $right;
}
return $value;
}
private function term() {
$tok = $this->peek();
if (T_ARRAY === $tok[0]) {
$this->i++;
$this->expect('(');
return $this->elements(')');
}
if ('[' === $tok[0]) {
$this->i++;
return $this->elements(']');
}
if (T_CONSTANT_ENCAPSED_STRING === $tok[0]) {
$this->i++;
return $this->string($tok[1]);
}
if (T_STRING === $tok[0] && isset($this->t[$this->i + 1]) && T_DOUBLE_COLON === $this->t[$this->i + 1][0]) {
$class = in_array(strtolower($tok[1]), array('self', 'static'), true) ? $this->class : $tok[1];
$name = $this->t[$this->i + 2][1];
$this->i += 3;
return $this->constant($class . '::' . $name);
}
if (T_STATIC === $tok[0] && isset($this->t[$this->i + 1]) && T_DOUBLE_COLON === $this->t[$this->i + 1][0]) {
$name = $this->t[$this->i + 2][1];
$this->i += 3;
return $this->constant($this->class . '::' . $name);
}
if (T_VARIABLE === $tok[0]) {
if (!isset($this->vars[$tok[1]])) {
$this->error("{$tok[1]} is not assigned before it is used");
}
$back = $this->i + 1;
$this->i = $this->vars[$tok[1]];
$value = $this->expr();
$this->i = $back;
return $value;
}
$this->error("cannot read '{$tok[1]}'");
}
private function elements($close) {
$out = array();
while ($close !== $this->peek()[0]) {
$key = $this->expr();
$this->expect(T_DOUBLE_ARROW);
$out[$key] = $this->expr();
if (',' === $this->peek()[0]) {
$this->i++;
} elseif ($close !== $this->peek()[0]) {
$this->error("expected , or $close");
}
}
$this->i++;
return $out;
}
private function string($text) {
if ("'" !== $text[0]) {
$this->error("only single-quoted strings are read: $text");
}
return strtr(substr($text, 1, -1), array("\\'" => "'", '\\\\' => '\\'));
}
private function constant($key) {
if (!isset($this->consts[$key])) {
$this->error("unknown constant $key");
}
list($file, $t, $start, $class) = $this->consts[$key];
$saved = array($this->file, $this->t, $this->i, $this->class);
list($this->file, $this->t, $this->i, $this->class) = array($file, $t, $start, $class);
$value = $this->expr();
list($this->file, $this->t, $this->i, $this->class) = $saved;
return $value;
}
}
/**
* GET a URL; the body, or null on a network error.
*
* @param string $url URL.
* @param bool $head Only the headers (returns the Content-Length).
* @return string|int|null
*/
function fetch($url, $head = false) {
$args = array('-sSL', '-m', '30', '-A', 'wpstarter-replaced-plugins');
if ($head) {
$args = array_merge($args, array('-o', '/dev/null', '-w', '%{http_code} %{size_download}'));
}
$cmd = 'curl ' . implode(' ', array_map('escapeshellarg', $args)) . ' ' . escapeshellarg($url);
exec($cmd, $lines, $status);
if (0 !== $status) {
return null;
}
$out = implode("\n", $lines);
if ($head) {
list($code, $bytes) = explode(' ', $out) + array('', '0');
return '200' === $code ? (int) $bytes : null;
}
return $out;
}
/**
* Size of a plugin's current zip: WordPress.org first, then GITHUB_SOURCES.
*
* @param string $slug Plugin folder.
* @return int|null Bytes, or null when it cannot be downloaded.
*/
function zip_size($slug) {
$info = fetch('https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Bslug%5D=' . rawurlencode($slug) . '&request%5Bfields%5D%5Bsections%5D=0');
if (null === $info) {
fail("could not reach WordPress.org for $slug");
}
$data = json_decode($info, true);
if (is_array($data) && !empty($data['download_link'])) {
$bytes = fetch($data['download_link'], true);
if (null === $bytes) {
fail("could not download {$data['download_link']}");
}
return $bytes;
}
if (isset(GITHUB_SOURCES[$slug])) {
$json = fetch('https://api.github.com/repos/' . GITHUB_SOURCES[$slug] . '/releases/latest');
$data = null === $json ? null : json_decode($json, true);
foreach (is_array($data) && isset($data['assets']) ? $data['assets'] : array() as $asset) {
if (0 === strpos($asset['name'], $slug) && '.zip' === substr($asset['name'], -4)) {
return (int) $asset['size'];
}
}
fail('no release zip for ' . GITHUB_SOURCES[$slug]);
}
return null; // Pro editions and plugins closed on WordPress.org.
}
/**
* Bytes as "1.2 MB" or "870 KB".
*
* @param int $bytes Bytes.
* @return string
*/
function size_label($bytes) {
if ($bytes >= 1024 * 1024) {
return number_format($bytes / (1024 * 1024), 1) . ' MB';
}
return number_format($bytes / 1024) . ' KB';
}
$mode = '';
$dir = dirname(__DIR__);
foreach (array_slice($argv, 1) as $arg) {
if ('--check' === $arg || '--write' === $arg) {
$mode = $arg;
} elseif ('-h' === $arg || '--help' === $arg) {
echo "Usage: php scripts/replaced-plugins.php [--check | --write] [DIR]\n";
exit(0);
} elseif ('-' !== $arg[0]) {
$dir = rtrim($arg, '/');
} else {
fail("unknown argument: $arg");
}
}
if (!is_dir($dir . '/includes')) {
fail("$dir has no includes folder");
}
$reader = new Replaces_Reader();
$reader->read($dir);
$count = count($reader->plugins);
$readme_file = $dir . '/README.md';
$readme = is_file($readme_file) ? file_get_contents($readme_file) : '';
if (0 === $count) {
// A plugin that replaces none has no count line in README.md. With the
// line there, finding none means the reader no longer understands the code.
if (preg_match(README_LINE, $readme)) {
fail('no replaced plugins found; the reader is out of date');
}
echo "No replaced plugins, and README.md has no count line\n";
exit(0);
}
if ('' === $mode) {
foreach ($reader->plugins as $slug => $name) {
printf("%-42s %s\n", $slug, $name);
}
printf("%d plugins\n", $count);
exit(0);
}
if ('--check' === $mode) {
if (!preg_match(README_LINE, $readme, $m)) {
fwrite(STDERR, "README.md has no \"WP Plugin Starter replaces **N plugins**\" line\n");
exit(1);
}
if ((int) $m[1] !== $count) {
fwrite(STDERR, "README.md says {$m[1]} replaced plugins, the code has $count; run php scripts/replaced-plugins.php --write\n");
exit(1);
}
echo "README.md says $count replaced plugins, as the code does\n";
exit(0);
}
// --write: sizes of every replaced plugin that can be downloaded, and ours.
$total = 0;
$sized = 0;
$no_size = array();
foreach ($reader->plugins as $slug => $name) {
$bytes = zip_size($slug);
if (null === $bytes) {
$no_size[] = $name;
continue;
}
$total += $bytes;
$sized++;
printf("%-42s %10s\n", $slug, size_label($bytes));
}
$out = sys_get_temp_dir() . '/wpstarter-replaced-' . getmypid();
exec('cd ' . escapeshellarg($dir) . ' && scripts/build-release.sh --ref HEAD --out ' . escapeshellarg($out) . ' --quiet', $zips, $status);
if (0 !== $status || empty($zips[0]) || !is_file($zips[0])) {
fail('scripts/build-release.sh failed');
}
$ours = filesize($zips[0]);
array_map('unlink', glob($out . '/*.zip'));
@rmdir($out);
$line = sprintf(
'WP Plugin Starter replaces **%d plugins**, some of them in part, with free and Pro editions counted separately. The %d that can be downloaded come to %s zipped; WP Plugin Starter is %s.',
$count,
$sized,
size_label($total),
size_label($ours)
);
printf("%d plugins; %d downloadable, %s; WP Plugin Starter %s\n", $count, $sized, size_label($total), size_label($ours));
if ($no_size) {
echo 'Not downloadable (Pro or closed): ' . implode(', ', $no_size) . "\n";
}
if (!preg_match(README_LINE, $readme)) {
fail('README.md has no "WP Plugin Starter replaces **N plugins**" line to update; add one above the Features table');
}
file_put_contents($readme_file, preg_replace(README_LINE, str_replace(array('\\', '$'), array('\\\\', '\\$'), $line), $readme, 1));
echo "Updated README.md\n";
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# Smoke test: install the release zip on a disposable WordPress in Docker and
# load the site and admin screens, first with the default settings and then
# with every feature switched on. Then uninstall it and check it left nothing
# behind. Nothing is kept and no other site is touched.
#
# Usage: scripts/smoke-test.sh [--wp VERSION] [--php VERSION] [--ref REF]
# [--zip FILE] [--wporg] [--keep-log FILE]
# --wp VERSION WordPress version (default: latest). The minimum is 6.2.
# --php VERSION PHP version of the Docker images (default: 8.3). The
# minimum is 7.4.
# --ref REF Build the zip from REF (default: HEAD).
# --zip FILE Test this zip instead of building one.
# --wporg Test the WordPress.org build instead of the GitHub one.
# --keep-log FILE Save the site's debug.log to FILE.
#
# Fails on any PHP error, warning, notice or deprecation in debug.log (from
# the pages loaded, WP-CLI and cron), a page that fails to load (HTTP 500 or
# WordPress's critical error page), or settings left after uninstalling.
# Needs Docker, curl and internet access (WordPress is downloaded).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
# Set in main() from the plugin's main file.
SLUG=""
DB_IMAGE=""
NAME=""
TMP_DIR=""
STARTED=0
WP_VERSION="latest"
PHP_VERSION="8.3"
BASE_URL=""
DB_PASSWORD=""
ADMIN_PASSWORD=""
FAILED=0
die() {
local message="$1"
printf 'smoke-test: %s\n' "$message" >&2
exit 2
}
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
fail() {
local message="$1"
printf ' FAIL %s\n' "$message"
FAILED=1
return 0
}
cleanup() {
if [ "$STARTED" -eq 1 ]; then
docker rm -f "$NAME-web" "$NAME-db" >/dev/null 2>&1 || true
docker volume rm "$NAME-wp" >/dev/null 2>&1 || true
docker network rm "$NAME" >/dev/null 2>&1 || true
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
return 0
}
random_password() {
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 24
return 0
}
# wp-cli in the disposable site; the zip is mounted at /zips.
wp_cli() {
docker run --rm --network "$NAME" -v "$NAME-wp:/var/www/html" -v "$TMP_DIR/zips:/zips:ro" \
--user 33:33 "wordpress:cli-php$PHP_VERSION" php -d memory_limit=1G /usr/local/bin/wp "$@"
return $?
}
start_site() {
printf 'Starting a disposable WordPress %s on PHP %s (%s)...\n' "$WP_VERSION" "$PHP_VERSION" "$NAME"
STARTED=1
DB_PASSWORD="$(random_password)"
ADMIN_PASSWORD="$(random_password)"
docker network create "$NAME" >/dev/null
docker volume create "$NAME-wp" >/dev/null
docker run -d --name "$NAME-db" --network "$NAME" \
-e MARIADB_ROOT_PASSWORD="$DB_PASSWORD" -e MARIADB_DATABASE=wordpress "$DB_IMAGE" >/dev/null
docker run --rm -v "$NAME-wp:/var/www/html" --user 0:0 "wordpress:cli-php$PHP_VERSION" chown 33:33 /var/www/html
local waited=0
until docker exec "$NAME-db" mariadb-admin ping -h127.0.0.1 -uroot -p"$DB_PASSWORD" --silent >/dev/null 2>&1; do
[ "$waited" -lt 90 ] || die "the database did not start"
sleep 2
waited=$((waited + 2))
done
# Core first, so the web image finds WordPress and does not copy its own.
wp_cli core download --version="$WP_VERSION" --quiet
docker run -d --name "$NAME-web" --network "$NAME" -v "$NAME-wp:/var/www/html" \
-p 127.0.0.1::80 "wordpress:php$PHP_VERSION-apache" >/dev/null
BASE_URL="http://$(docker port "$NAME-web" 80 | head -n 1)"
wp_cli config create --dbname=wordpress --dbuser=root --dbpass="$DB_PASSWORD" --dbhost="$NAME-db" --skip-check --quiet
wp_cli config set WP_DEBUG true --raw --quiet
wp_cli config set WP_DEBUG_LOG true --raw --quiet
wp_cli config set WP_DEBUG_DISPLAY false --raw --quiet
wp_cli config set DISABLE_WP_CRON true --raw --quiet
wp_cli core install --url="$BASE_URL" --title=SmokeTest --admin_user=admin --admin_password="$ADMIN_PASSWORD" \
--admin_email=admin@example.com --skip-email --quiet
# WP-CLI cannot see Apache's mod_rewrite, so write core's rules itself.
wp_cli rewrite structure '/%postname%/' --quiet
docker exec -u www-data "$NAME-web" sh -c 'printf "%s\n" "# BEGIN WordPress" "RewriteEngine On" "RewriteBase /" \
"RewriteRule ^index\.php$ - [L]" "RewriteCond %{REQUEST_FILENAME} !-f" "RewriteCond %{REQUEST_FILENAME} !-d" \
"RewriteRule . /index.php [L]" "# END WordPress" >/var/www/html/.htaccess'
printf 'WordPress %s at %s\n' "$(wp_cli core version)" "$BASE_URL"
return 0
}
log_in() {
curl -sS -o /dev/null -c "$TMP_DIR/cookies" -b 'wordpress_test_cookie=WP%20Cookie%20check' \
--data-urlencode 'log=admin' --data-urlencode "pwd=$ADMIN_PASSWORD" \
--data-urlencode 'testcookie=1' --data-urlencode "redirect_to=$BASE_URL/wp-admin/" \
"$BASE_URL/wp-login.php"
local status
status="$(curl -sS -o /dev/null -w '%{http_code}' -b "$TMP_DIR/cookies" "$BASE_URL/wp-admin/")"
[ "$status" = "200" ] || die "could not log in (wp-admin answered $status)"
return 0
}
# Load one page; $2 is "admin" to send the login cookie. Fails on 5xx or
# WordPress's critical error page; other statuses are listed.
fetch() {
local path="$1"
local who="$2"
local status
local args=(-sS -o "$TMP_DIR/page" -w '%{http_code}' --max-time 60)
[ "$who" = "admin" ] && args+=(-b "$TMP_DIR/cookies")
status="$(curl "${args[@]}" "$BASE_URL$path" || printf '000')"
if grep -q 'There has been a critical error' "$TMP_DIR/page"; then
fail "$status $who $path: critical error page"
elif [ "$status" -ge 500 ] || [ "$status" = "000" ]; then
fail "$status $who $path"
else
printf ' %s %s %s\n' "$status" "$who" "$path"
fi
return 0
}
load_pages() {
local path
local visitor_pages=(
'/' '/hello-world/' '/sample-page/' '/?s=hello' '/category/uncategorized/' '/feed/'
'/wp-json/' '/wp-json/wp/v2/posts' '/wp-sitemap.xml' '/no-such-page/' '/wp-login.php'
)
local admin_pages=(
'/' '/hello-world/' '/wp-admin/' '/wp-admin/plugins.php' "/wp-admin/options-general.php?page=$SLUG"
'/wp-admin/edit.php' '/wp-admin/edit.php?post_type=page' '/wp-admin/post-new.php'
'/wp-admin/post.php?post=1&action=edit' '/wp-admin/upload.php' '/wp-admin/edit-comments.php'
'/wp-admin/themes.php' '/wp-admin/users.php' '/wp-admin/profile.php' '/wp-admin/tools.php'
'/wp-admin/site-health.php' '/wp-admin/options-general.php' '/wp-admin/options-permalink.php'
)
for path in "${visitor_pages[@]}"; do
fetch "$path" visitor
done
for path in "${admin_pages[@]}"; do
fetch "$path" admin
done
wp_cli cron event run --due-now --quiet || fail "wp cron event run"
return 0
}
# Switch on every feature that has an on/off setting, except maintenance
# mode, which would answer every visitor page with its 503 notice.
switch_all_on() {
# The class prefix is the plugin's @package (scripts/lib/plugin.sh).
# shellcheck disable=SC2016 # PHP code: its $variables are PHP's, not the shell's.
wp_cli eval '
$plugin = "'"$PLUGIN_PACKAGE"'";
$settings = $plugin . "_Settings";
$schema = $settings::schema();
$on = 0;
foreach ($plugin::features() as $class) {
$key = defined($class . "::KEY") ? $class::KEY : "";
if ("" !== $key && "maintenance" !== $key && isset($schema[$key]["type"]) && in_array($schema[$key]["type"], array("bool", "boolean"), true)) {
$settings::set($key, true);
$on++;
}
}
echo "$on features switched on\n";'
return $?
}
check_uninstall() {
wp_cli plugin deactivate "$SLUG" --quiet || fail "deactivate"
wp_cli plugin uninstall "$SLUG" --quiet || fail "uninstall"
local left
left="$(wp_cli option list --search="*$PLUGIN_PREFIX*" --field=option_name 2>/dev/null || true)"
[ -z "$left" ] || fail "options left after uninstalling: $(printf '%s' "$left" | tr '\n' ' ')"
left="$(wp_cli cron event list --field=hook 2>/dev/null | grep -i "$PLUGIN_PREFIX" || true)"
[ -z "$left" ] || fail "cron events left after uninstalling: $(printf '%s' "$left" | tr '\n' ' ')"
return 0
}
# A must-use plugin that logs a notice for /?smoke-canary, so the test can
# prove that page loads reach debug.log (an empty log proves nothing otherwise).
add_canary() {
docker exec -u www-data "$NAME-web" sh -c 'mkdir -p /var/www/html/wp-content/mu-plugins && printf "%s\n" "<?php" \
"if (isset(\$_GET[\"smoke-canary\"])) { trigger_error(\"smoke-test canary\", E_USER_NOTICE); }" \
>/var/www/html/wp-content/mu-plugins/smoke-canary.php'
return $?
}
check_debug_log() {
local keep="$1"
local log
fetch '/?smoke-canary' visitor
log="$(docker exec "$NAME-web" sh -c 'cat /var/www/html/wp-content/debug.log 2>/dev/null' || true)"
if [ -n "$keep" ]; then
printf '%s\n' "$log" >"$keep"
fi
if ! printf '%s\n' "$log" | grep -q 'smoke-test canary'; then
fail "debug.log does not work: the canary notice is missing"
return 0
fi
log="$(printf '%s\n' "$log" | grep -v 'smoke-test canary' || true)"
if [ -n "$log" ]; then
printf '\ndebug.log:\n%s\n' "$log"
fail "PHP messages in debug.log"
else
printf '\ndebug.log: no PHP messages (the canary notice arrived)\n'
fi
return 0
}
main() {
local ref="HEAD"
local zip=""
local wporg=0
local keep=""
local arg
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
--wp | --php | --ref | --zip | --keep-log)
[ $# -ge 2 ] || die "$arg needs a value"
case "$arg" in
--wp) WP_VERSION="$2" ;;
--php) PHP_VERSION="$2" ;;
--ref) ref="$2" ;;
--zip)
[ -f "$2" ] || die "no such zip: $2"
zip="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
;;
--keep-log)
case "$2" in
/*) keep="$2" ;;
*) keep="$PWD/$2" ;;
esac
;;
esac
shift
;;
--wporg) wporg=1 ;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
command -v docker >/dev/null 2>&1 || die "needs Docker"
command -v curl >/dev/null 2>&1 || die "needs curl"
docker info >/dev/null 2>&1 || die "Docker is not running"
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
plugin_identity "$ref" || die "cannot tell which plugin this is at $ref"
SLUG="$PLUGIN_SLUG"
DB_IMAGE="$(plugin_env DB_IMAGE mariadb:10.6)"
NAME="$SLUG-smoke-$$"
trap cleanup EXIT
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/$SLUG-smoke.XXXXXX")"
mkdir -p "$TMP_DIR/zips"
chmod 755 "$TMP_DIR" "$TMP_DIR/zips"
if [ -z "$zip" ]; then
"$root/scripts/build-release.sh" --ref "$ref" --out "$TMP_DIR/build" --quiet >/dev/null || die "build failed"
local built=()
if [ "$wporg" -eq 1 ]; then
built=("$TMP_DIR"/build/wordpress-org-"$SLUG"-*.zip)
else
built=("$TMP_DIR"/build/"$SLUG"-*.zip)
fi
[ -f "${built[0]}" ] || die "the build made no zip"
zip="${built[0]}"
fi
cp "$zip" "$TMP_DIR/zips/$SLUG.zip"
chmod 644 "$TMP_DIR/zips/$SLUG.zip"
start_site
add_canary || die "could not add the debug.log canary"
printf '\nInstalling %s\n' "$(basename "$zip")"
wp_cli plugin install "/zips/$SLUG.zip" --activate --quiet || die "could not install and activate the plugin"
log_in
printf '\nDefault settings:\n'
load_pages
printf '\nEvery feature on:\n'
switch_all_on || fail "switching features on"
load_pages
printf '\nUninstall:\n'
check_uninstall
check_debug_log "$keep"
printf '\n'
if [ "$FAILED" -eq 1 ]; then
printf 'Smoke test failed (WordPress %s, PHP %s).\n' "$WP_VERSION" "$PHP_VERSION"
return 1
fi
printf 'Smoke test passed (WordPress %s, PHP %s).\n' "$WP_VERSION" "$PHP_VERSION"
return 0
}
main "$@"
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Copy the core files (scripts/core-files.txt) from the starter plugin into
# this plugin, with this plugin's names. Core files hold no code for one
# plugin, so after a sync they match the starter apart from the names.
#
# Usage: scripts/sync-core.sh [--check] [--from DIR] [--ref REF]
# --check Change nothing; list core files that differ from the starter's
# and exit 1 if any do (scripts/preflight-release.sh warns).
# --from DIR A checkout of the starter (default: <PREFIX>_STARTER_DIR, then
# wp-plugin-starter-template-for-ai-coding next to this repository).
# --ref REF Starter commit, branch or tag to copy from (default: HEAD of
# that checkout; use origin/main after a git fetch there).
#
# The list of core files and their contents come from the starter at REF;
# the plugin's names come from this repository's main file at HEAD.
# Review the changes with git diff, then commit them.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=scripts/lib/plugin.sh disable=SC1091 # followed only with -x
. "$SCRIPT_DIR/lib/plugin.sh"
readonly STARTER_REPO_DIR="wp-plugin-starter-template-for-ai-coding"
die() {
local message="$1"
printf 'sync-core: %s\n' "$message" >&2
exit 2
}
TMP_FILE=""
cleanup() {
[ -z "$TMP_FILE" ] || rm -f "$TMP_FILE"
return 0
}
usage() {
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'
return 0
}
# Core paths at the starter ref, one per line, with directories expanded.
core_paths() {
local from="$1"
local ref="$2"
local line
git -C "$from" show "$ref:scripts/core-files.txt" | sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' | while IFS= read -r line; do
case "$line" in
*/) git -C "$from" ls-tree -r --name-only "$ref" -- "$line" ;;
*) printf '%s\n' "$line" ;;
esac
done
return 0
}
main() {
local check=0 from="" ref="HEAD"
while [ $# -gt 0 ]; do
local arg="$1"
local value="${2:-}"
case "$arg" in
--check) check=1 ;;
--from)
[ $# -ge 2 ] || die "--from needs a value"
from="$value"
shift
;;
--ref)
[ $# -ge 2 ] || die "--ref needs a value"
ref="$value"
shift
;;
-h | --help)
usage
return 0
;;
*) die "unknown argument: $arg" ;;
esac
shift
done
local root
root="$(git rev-parse --show-toplevel)" || die "run this inside a checkout of the plugin"
cd "$root"
plugin_identity HEAD || die "cannot tell which plugin this is"
plugin_names_as TO
if [ -z "$from" ]; then
from="$(plugin_env STARTER_DIR "$(dirname "$root")/$STARTER_REPO_DIR")"
fi
[ -d "$from" ] || die "no starter checkout at $from; clone it there or pass --from DIR"
from="$(cd "$from" && pwd)"
[ "$from" != "$root" ] || die "this is the starter; run it in a plugin made from the starter"
git -C "$from" rev-parse --verify --quiet "$ref^{commit}" >/dev/null || die "not a commit in the starter: $ref"
cd "$from"
plugin_identity "$ref" || die "cannot read the starter's names at $ref"
plugin_names_as FROM
cd "$root"
printf 'Core files from %s (%s) into %s, as %s / %s / %s / %s / %s\n' \
"$FROM_NAME" "$(git -C "$from" rev-parse --short "$ref")" "$TO_NAME" \
"$TO_SLUG" "$TO_PREFIX" "$TO_PACKAGE" "$TO_CONST" "$TO_CSS"
local path target mode differ=0 count=0
trap cleanup EXIT
TMP_FILE="$(mktemp "${TMPDIR:-/tmp}/sync-core.XXXXXX")"
local tmp="$TMP_FILE"
while IFS= read -r path; do
target="$(printf '%s' "$path" | plugin_map)"
git -C "$from" show "$ref:$path" | plugin_map >"$tmp"
count=$((count + 1))
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
continue
fi
differ=$((differ + 1))
if [ "$check" -eq 1 ]; then
if [ -f "$target" ]; then
printf ' differs %s\n' "$target"
else
printf ' missing %s\n' "$target"
fi
continue
fi
mkdir -p "$(dirname "$target")"
cp "$tmp" "$target"
mode="$(git -C "$from" ls-tree "$ref" -- "$path" | awk '{ print $1 }')"
if [ "$mode" = "100755" ]; then chmod +x "$target"; else chmod -x "$target"; fi
printf ' updated %s\n' "$target"
done < <(core_paths "$from" "$ref")
if [ "$check" -eq 1 ]; then
if [ "$differ" -eq 0 ]; then
printf 'All %d core files match the starter.\n' "$count"
return 0
fi
printf '%d of %d core files differ from the starter. Run scripts/sync-core.sh, or change the starter first.\n' "$differ" "$count"
return 1
fi
printf '%d of %d core files updated. Review with git diff, then commit.\n' "$differ" "$count"
return 0
}
main "$@"
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* Remove WP Plugin Starter's settings and caches on uninstall (every site on
* multisite). Add every option, post meta, user meta, transient, cron hook
* and file a feature stores (STANDARDS.md → Structure).
*
* @package WPStarter
*/
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
/**
* Delete the plugin's options and transients for the current site.
*/
function wpstarter_uninstall_site() {
global $wpdb;
delete_option('wpstarter_options');
delete_option('wpstarter_db_version');
$patterns = array('_transient_wpstarter_', '_transient_timeout_wpstarter_');
foreach ($patterns as $pattern) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-off cleanup of our transients.
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like($pattern) . '%'));
}
}
if (is_multisite()) {
foreach (get_sites(array('fields' => 'ids', 'number' => 0)) as $wpstarter_site_id) {
switch_to_blog($wpstarter_site_id);
wpstarter_uninstall_site();
restore_current_blog();
}
} else {
wpstarter_uninstall_site();
}
// Hidden "WP Plugin Starter can do the job of these plugins" lines.
delete_metadata('user', 0, 'wpstarter_replaced_plugins_hidden', '', true);
// Latest GitHub releases (the shared GitHub updater). Only a cache: another
// plugin's copy asks again.
delete_site_transient('wpallstars_github_releases');
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* Plugin Name: WP Plugin Starter
* Plugin URI: https://github.com/wpallstars/wp-plugin-starter-template-for-ai-coding
* Description: A clean start for a WordPress plugin: a settings screen, a Read Me tab, updates from GitHub and release scripts, ready for your features.
* Version: 1.0.0
* Requires at least: 6.2
* Requires PHP: 7.4
* Author: Marcus Quinn
* Author URI: https://www.wpallstars.com/
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: wp-plugin-starter-template
* GitHub Plugin URI: wpallstars/wp-plugin-starter-template-for-ai-coding
* Primary Branch: main
* Release Asset: true
*
* WP Plugin Starter is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* any later version.
*
* WP Plugin Starter is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* @package WPStarter
*/
if (!defined('ABSPATH')) {
exit;
}
define('WPSTARTER_VERSION', '1.0.0');
define('WPSTARTER_FILE', __FILE__);
define('WPSTARTER_DIR', plugin_dir_path(__FILE__));
define('WPSTARTER_URL', plugin_dir_url(__FILE__));
require_once WPSTARTER_DIR . 'includes/class-wpstarter.php';
WPStarter::load();
register_deactivation_hook(__FILE__, array('WPStarter', 'deactivate'));
// Translations load just-in-time from WordPress.org language packs (WP 4.6+).