Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

# The oldest PHP the plugin supports (Requires PHP in plugin.php,
# and the live server).
Expand All @@ -33,7 +33,7 @@ jobs:
coverage: none

- name: Cache Composer packages
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cache/composer/files
key: composer-${{ hashFiles('composer.lock') }}
Expand Down Expand Up @@ -63,21 +63,21 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

# PHPUnit 12's minimum.
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none

- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 20
node-version-file: .nvmrc
cache: 'npm'

- name: Cache Composer packages
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cache/composer/files
key: composer-${{ hashFiles('composer.lock') }}
Expand All @@ -97,11 +97,11 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 20
node-version-file: .nvmrc
cache: 'npm'

- name: Install Node dependencies
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 20
node-version-file: .nvmrc
cache: 'npm'

- name: Install Node dependencies
Expand All @@ -33,7 +33,7 @@ jobs:
run: ./node_modules/.bin/wp-scripts plugin-zip

- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: jc-core-functionality.zip
generate_release_notes: true
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
51 changes: 27 additions & 24 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,36 @@ A site-specific WordPress plugin for jasonchafin.com. It holds functionality tha

## Commands

Neither `vendor/` nor `node_modules/` is committed. Run `composer install` and `npm install` first.

- PHP lint: `composer lint` (ruleset in `phpcs.xml.dist`: WordPress standard, `jc` prefix, `jc-core-functionality` text domain, `tests/` excluded). Auto-fix with `composer lint-fix`. Lint one file with `vendor/bin/phpcs <file>`.
- Format: `npm run format` (wp-scripts/Prettier). `acf-json/` and `CHANGELOG.md` are excluded in `.prettierignore` because tools write them.
- Tests:
- JS/config checks: `npm run test:unit` (Node's built-in `node:test`, files in `tests/*.test.js`). Run one file with `node --test tests/<name>.test.js`.
- PHP: `composer test` or `npm run test:php` (PHPUnit, files in `tests/php/`). Run one test with `vendor/bin/phpunit --filter <name>`.
- The PHP tests run without WordPress. `tests/php/bootstrap.php` stubs only the WP functions the loaded feature files call, and records the calls. A new feature file under test needs its WP calls stubbed there and a `require_once` added.
- `npm test` runs both suites.
- PHPUnit 12 needs PHP 8.3, but the plugin supports 8.0 (`Requires PHP`). Keep plugin code 8.0-compatible; CI checks it with `php -l` on 8.0.
- Hooks and CI: `npm install` sets up a husky pre-commit hook that runs lint-staged (PHPCS on staged PHP, `wp-scripts format` on staged JS/JSON/YAML/Markdown). `.github/workflows/ci.yml` runs on every PR: PHP 8.0 syntax and PHPCS, tests on PHP 8.3, `lint:pkg-json` and a formatting check.
- Release zip: `npm run zip`. It packages only the `files` list in `package.json` into a `jc-core-functionality/` root folder. Run `composer install --no-dev` first, or dev dependencies end up in `vendor/` in the zip.
- Release: `npm run release` (commit-and-tag-version). It bumps `package.json`, `package-lock.json` and the `Version:` header in `plugin.php` (via `wp-plugin-version-updater.js`), updates `CHANGELOG.md` from Conventional Commits, and tags `vX.Y.Z`. Pushing the tag triggers `.github/workflows/release.yml`, which runs `composer install --no-dev` and `plugin-zip`, then attaches `jc-core-functionality.zip` to a GitHub release.
- Release candidates: `npm run release -- --prerelease rc` tags `vX.Y.Z-rc.N`. The workflow publishes `-rc` tags as GitHub prereleases. PUC reads `/releases/latest`, which skips prereleases, so installed sites never see RCs. To test an RC on staging, install its zip by hand. If an RC is ever published without the prerelease flag, every site with the updater is offered it.
Neither `vendor/` nor `node_modules/` is committed. Run `composer install` and `npm install` first. Use Node 24 (`.nvmrc`; `nvm use`), which CI and the release workflow also read; `engines.node` follows `@wordpress/scripts`.

- PHP lint: `composer lint` (ruleset in `phpcs.xml.dist`: WordPress standard, `jc` prefix, `jc-core-functionality` text domain, `tests/` excluded). Auto-fix with `composer lint-fix`. Lint one file with `vendor/bin/phpcs <file>`.
- Format: `npm run format` (wp-scripts/Prettier). `acf-json/` and `CHANGELOG.md` are excluded in `.prettierignore` because tools write them.
- Tests:
- JS/config checks: `npm run test:unit` (Node's built-in `node:test`, files in `tests/*.test.js`). Run one file with `node --test tests/<name>.test.js`.
- PHP: `composer test` or `npm run test:php` (PHPUnit, files in `tests/php/`). Run one test with `vendor/bin/phpunit --filter <name>`.
- The PHP tests run without WordPress. `tests/php/bootstrap.php` stubs only the WP functions the loaded feature files call, and records the calls. A new feature file under test needs its WP calls stubbed there and a `require_once` added.
- `npm test` runs both suites.
- PHPUnit 12 needs PHP 8.3, but the plugin supports 8.0 (`Requires PHP`). Keep plugin code 8.0-compatible; CI checks it with `php -l` on 8.0.
- Hooks and CI: `npm install` sets up a husky pre-commit hook that runs lint-staged (PHPCS on staged PHP, `wp-scripts format` on staged JS/JSON/YAML/Markdown). `.github/workflows/ci.yml` runs on every PR: PHP 8.0 syntax and PHPCS, tests on PHP 8.3, `lint:pkg-json` and a formatting check.
- Release zip: `npm run zip`. It packages only the `files` list in `package.json` into a `jc-core-functionality/` root folder. Run `composer install --no-dev` first, or dev dependencies end up in `vendor/` in the zip.
- Release: `npm run release` (commit-and-tag-version). It bumps `package.json`, `package-lock.json` and the `Version:` header in `plugin.php` (via `wp-plugin-version-updater.js`), updates `CHANGELOG.md` from Conventional Commits, and tags `vX.Y.Z`. Pushing the tag triggers `.github/workflows/release.yml`, which runs `composer install --no-dev` and `plugin-zip`, then attaches `jc-core-functionality.zip` to a GitHub release.
- Release candidates: `npm run release -- --prerelease rc` tags `vX.Y.Z-rc.N`. The workflow publishes `-rc` tags as GitHub prereleases. PUC reads `/releases/latest`, which skips prereleases, so installed sites never see RCs. To test an RC on staging, install its zip by hand. If an RC is ever published without the prerelease flag, every site with the updater is offered it.

## Architecture

- `plugin.php` is the only bootstrap. It defines `JC_DIR`, registers the update checker (below), adds a Settings link on the Plugins screen, and `include_once`s each file in `lib/functions/`. A new feature file needs its own include line added there. Nothing is namespaced or class-based: everything is global functions with a `jc_` prefix, hooked at file load.
- **Post types and custom fields live in `acf-json/`, not in PHP.** `general.php` redirects ACF's JSON load/save point to `JC_DIR . '/acf-json'`, and deliberately removes the theme's default load path, so the plugin is the only source of the content model. The `person` (People) and `quote` (Quotes) CPTs and their field groups are created by ACF from these JSON files. Edit them in the ACF admin UI so the JSON is rewritten, rather than by hand.
- Code depends on these ACF-defined objects by name. The `[quotes]` shortcode (`shortcodes.php`) queries post type `quote`. `general.php` registers the `subtitle` post meta for REST, and that name matches the ACF "Posts" field group.
- Block Bindings sources are registered in `general.php`: `jc/copyright` and `jc/user-data` (args `key` = `name|description|avatar` and `userId`). The settings page in `settings.php` is a static info page that documents features. Update it when you add user-facing features.
- **Updates come from GitHub releases** through plugin-update-checker (PUC), a Composer runtime dependency loaded from `vendor/`. It runs only in admin, cron and WP-CLI, and installs only the `jc-core-functionality.zip` release asset. The slug `jc-core-functionality` must match in `plugin.php`, `uninstall.php` (PUC's option, transient and cron names), `package.json` `name` and the release workflow. `tests/update-checker-config.test.js` enforces this.
- `uninstall.php` removes only PUC's data. By policy, `person` and `quote` content is never deleted.
- `gtm.php` loads GTM container `GTM-WNP9BDSD` by default (override with a `JC_GTM_ID` constant or the `jc_gtm_container_id` filter; `''` or an invalid ID disables it), and skips users with `manage_options`. `security-headers.php` builds a nonce-based CSP (`jc_csp_directives()`, filterable through `jc_csp_directives`) and sends it as Report-Only until `jc_csp_report_only` returns false. Admin and the Customizer preview are excluded. `csp-report.php` adds the `/wp-json/jc/v1/csp-report` endpoint, which logs violations as `[jc-csp]` lines in the PHP error log. Scripts must be printed through the script API to get the nonce; `tests/code-standards.test.js` rejects raw `<script>` tags in PHP. A new third-party host needs adding to the right directive in `jc_csp_directives()`, not to a single string.
- `plugin.php` is the only bootstrap. It defines `JC_DIR`, registers the update checker (below), adds a Settings link on the Plugins screen, and `include_once`s each file in `lib/functions/`. A new feature file needs its own include line added there. Nothing is namespaced or class-based: everything is global functions with a `jc_` prefix, hooked at file load.
- **Post types and custom fields live in `acf-json/`, not in PHP.** `general.php` redirects ACF's JSON load/save point to `JC_DIR . '/acf-json'`, and deliberately removes the theme's default load path, so the plugin is the only source of the content model. The `person` (People) and `quote` (Quotes) CPTs and their field groups are created by ACF from these JSON files. Edit them in the ACF admin UI so the JSON is rewritten, rather than by hand.
- Code depends on these ACF-defined objects by name. The `[quotes]` shortcode (`shortcodes.php`) queries post type `quote`. `general.php` registers the `subtitle` post meta for REST, and that name matches the ACF "Posts" field group.
- Block Bindings sources are registered in `general.php`: `jc/copyright` and `jc/user-data` (args `key` = `name|description|avatar` and `userId`). The settings page in `settings.php` is a static info page that documents features. Update it when you add user-facing features.
- **Updates come from GitHub releases** through plugin-update-checker (PUC), a Composer runtime dependency loaded from `vendor/`. It runs only in admin, cron and WP-CLI, and installs only the `jc-core-functionality.zip` release asset. The slug `jc-core-functionality` must match in `plugin.php`, `uninstall.php` (PUC's option, transient and cron names), `package.json` `name` and the release workflow. `tests/update-checker-config.test.js` enforces this.
- `uninstall.php` removes only PUC's data. By policy, `person` and `quote` content is never deleted.
- `gtm.php` loads GTM container `GTM-WNP9BDSD` by default (override with a `JC_GTM_ID` constant or the `jc_gtm_container_id` filter; `''` or an invalid ID disables it), and skips users with `manage_options`. `security-headers.php` builds a nonce-based CSP (`jc_csp_directives()`, filterable through `jc_csp_directives`) and sends it as Report-Only until `jc_csp_report_only` returns false. Admin and the Customizer preview are excluded. `csp-report.php` adds the `/wp-json/jc/v1/csp-report` endpoint, which logs violations as `[jc-csp]` lines in the PHP error log. Scripts must be printed through the script API to get the nonce; `tests/code-standards.test.js` rejects raw `<script>` tags in PHP. A new third-party host needs adding to the right directive in `jc_csp_directives()`, not to a single string.

## Gotchas

- The release zip is built from the `files` list in `package.json`. A new top-level directory won't ship unless it's added there.
- If `vendor/` is missing from the zip, the updater does nothing and shows no error, because of the `file_exists()` guard.
- Every PHP file starts with an `ABSPATH` guard, every translation call uses the `jc-core-functionality` text domain, and dates use `wp_date()`, not `date()`. `tests/code-standards.test.js` enforces all three, so a new feature file needs the guard.
- `prettier` in `package.json` is aliased to `npm:wp-prettier`. `wp-scripts format` (the pre-commit hook and the CI format check) refuses to run unless `node_modules/prettier` is the WordPress fork. Don't replace it with plain `prettier`, even though other packages peer-depend on `prettier`.
- `npm audit` still reports high findings inside `@wordpress/scripts` (unpatched `braces`, `serialize-javascript` pinned by its webpack plugins). They're dev-only and unreachable from what this plugin runs; see #26 before chasing them with `overrides`.

- The release zip is built from the `files` list in `package.json`. A new top-level directory won't ship unless it's added there.
- If `vendor/` is missing from the zip, the updater does nothing and shows no error, because of the `file_exists()` guard.
- Every PHP file starts with an `ABSPATH` guard, every translation call uses the `jc-core-functionality` text domain, and dates use `wp_date()`, not `date()`. `tests/code-standards.test.js` enforces all three, so a new feature file needs the guard.
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Custom functionality for the [Jason Chafin](https://jasonchafin.com) WordPress s

## Requirements

- [Advanced Custom Fields Pro](https://www.advancedcustomfields.com/pro/), declared with `Requires Plugins`. WordPress won't activate this plugin without it.
- [Advanced Custom Fields Pro](https://www.advancedcustomfields.com/pro/), declared with `Requires Plugins`. WordPress won't activate this plugin without it.

## Installation

Expand All @@ -26,19 +26,19 @@ Once it's installed, updates come from this repository's GitHub releases through

Defined as ACF JSON in `acf-json/`, which the plugin registers as ACF's load and save path:

- **People** (`person`), with a Contact Info field group: name, title, phone, email, website, address, bio and photo.
- **Quotes** (`quote`).
- **Posts** get a `subtitle` field. It's also registered as post meta and exposed in the REST API.
- **People** (`person`), with a Contact Info field group: name, title, phone, email, website, address, bio and photo.
- **Quotes** (`quote`).
- **Posts** get a `subtitle` field. It's also registered as post meta and exposed in the REST API.

### Shortcode

- `[quotes]` shows one random quote title from the Quotes post type.
- `[quotes]` shows one random quote title from the Quotes post type.

### Block bindings

| Source | Arguments | Value |
| -------------- | --------------------------------------------------- | ------------------------------------------- |
| `jc/copyright` | none | `© <current year>` in the site's timezone |
| `jc/copyright` | none | `© <current year>` in the site's timezone |
| `jc/user-data` | `key` (`name`, `description` or `avatar`), `userId` | The user's display name, bio, or avatar URL |

Example:
Expand All @@ -51,7 +51,7 @@ Example:

### Site-wide

- **Google Tag Manager:** container snippets in `<head>` and right after `<body>`. They're not printed for administrators (`manage_options`), so your own visits stay out of analytics. The container defaults to the live site's. Change it or turn it off in `wp-config.php`:
- **Google Tag Manager:** container snippets in `<head>` and right after `<body>`. They're not printed for administrators (`manage_options`), so your own visits stay out of analytics. The container defaults to the live site's. Change it or turn it off in `wp-config.php`:

```php
define( 'JC_GTM_ID', 'GTM-XXXXXXX' ); // a different container
Expand All @@ -60,8 +60,8 @@ Example:

The `jc_gtm_container_id` filter does the same. Anything that isn't a valid `GTM-…` ID turns GTM off.

- **Security headers** on front-end responses: `Referrer-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Permissions-Policy` and a Content Security Policy (see below).
- **XML-RPC disabled:** all methods are removed and the RSD link is taken out of `<head>`, to block brute-force login attempts through `/xmlrpc.php`.
- **Security headers** on front-end responses: `Referrer-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Permissions-Policy` and a Content Security Policy (see below).
- **XML-RPC disabled:** all methods are removed and the RSD link is taken out of `<head>`, to block brute-force login attempts through `/xmlrpc.php`.

## Content Security Policy

Expand Down Expand Up @@ -103,6 +103,7 @@ Deleting the plugin removes only the update checker's stored data. **People and
## Development

```sh
nvm use # Node 24, from .nvmrc
composer install && npm install # npm install also sets up the pre-commit hook

npm test # node:test (tests/*.test.js), then PHPUnit (tests/php/)
Expand Down
Loading
Loading