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
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ADR-0026 — Builds take a lock, write atomically and clean up after themselves

- **Status:** Accepted
- **Date:** 2026-10-12
- **Deciders:** codad5
- **Affects:** `packages/build` (`BuildLock`, `Interruption`, `Progress`, `ZipPackager`, `DirectoryPackager`, `ProcessCommandRunner`)

## Context

The packager copied the project, ran commands, and wrote the zip directly to its final name. Three
things went wrong, or could:

- Two builds of one project (two CI jobs sharing a workspace, a watcher firing during a build) ran
`npm run build` in the same folder and wrote the same output; a reader could pick up a half-written zip.
- A build killed part-way (Ctrl+C, a CI timeout) left its temporary copy behind, and possibly a partial
zip or a half-emptied output folder that looked like a real one. A command it had started could keep
running.
- A slow step printed nothing, so a two-minute asset build looked hung.

## Decision

1. **One build per project at a time.** An operating-system file lock (`flock`) on a file in the system
temp directory, named from the project's resolved path. The system releases it when the process
ends however it ends, so there are no stale locks to clear. A second build fails at once saying
which process holds it, or waits (`waitForLock()`, `--wait`). The lock is outside the project so it
is never shipped or committed.
2. **Output is atomic.** A zip is written next to its target under a temporary name and renamed into
place when complete, then its checksum; a directory is built beside its target and swapped in, with
the previous one put back if the swap fails. A failed or stopped build leaves the previous output
whole.
3. **Interruption is cooperative.** SIGINT/SIGTERM (pcntl) or Ctrl+C (Windows) set a flag; the build
checks it between steps, several times a second while a command runs, and before each file it
writes. It stops by throwing, so the same cleanup runs as for any failure: the command and its
children are terminated (`taskkill /T` on Windows, `pkill -P` elsewhere), staging and partial output
are removed, the lock is released. Staging copies older than six hours left by builds that were
killed outright are swept at the start of the next build; recent ones may belong to a running build.
4. **Commands are watched, not blocked on.** The runner polls a temporary file the shell redirects the
command's output into, so it can call back several times a second without a non-blocking pipe, which
PHP cannot provide on Windows. The shell does the redirect, not a descriptor passed to `proc_open`: in
a long test run the descriptor form lost the child's output after certain earlier tests, for a cause
that was not identified.
5. **Progress is plain lines everywhere, plus a spinner on a real terminal.** Every step ends with its
duration. The spinner is drawn in place and erased when its step ends, so the screen shows what a log
would. It exists only when stdout is an interactive terminal and none of `CI`, `NO_COLOR`,
`TERM=dumb`, `quiet()`, `plain()` or a caller-supplied `withOutput()` handler applies, and on Windows
only if virtual-terminal sequences can be enabled. A command whose output is streamed shows that
output instead.

## Options considered

- **No lock; document "don't run two builds".** Cheap, but the failure is silent corruption.
- **A lock file with a PID inside.** Needs stale-lock detection, which is platform-specific and racy;
`flock` gets this for free.
- **Handle signals only with `pcntl`.** Leaves Windows, where most of this project's authors work, with
nothing; Windows has its own control handler.
- **A spinner library.** A dependency for forty lines of drawing, and it would not know when to stay
plain.

## Consequences

- A build that is killed with SIGKILL or a power cut still can't clean up; the sweep and the atomic
writes bound the damage to a temporary directory and a temporary file next to the output.
- `toDirectory()` needs room for two copies of the output while it swaps.
- Tests exercise real processes, real locks and, on Linux, a real SIGINT.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ADR-0027 — Multiple-choice fields render as checkboxes, with an optional chip picker

- **Status:** Accepted
- **Date:** 2026-10-12
- **Deciders:** codad5
- **Affects:** `Data\Field\Types\ChoiceType`, `ChoicePickerAssets`, `Field::display()`, `FieldValidator`, `resources/js/picker.js`, `resources/css/picker.css`

## Context

A `select` field with `->multiple()` rendered the browser's `<select multiple>`. A plain click selects one
item and clears the rest (Ctrl/Cmd+click adds), which most people don't know, it looks different in every
browser and is hard to use on touch screens. 0.x plugins fixed this with jQuery "Chosen".

It also had a correctness problem: with nothing selected the browser submits nothing, the meta box
treats a missing key as "not on this form", and a saved selection could never be cleared.

## Decision

1. A multiple-choice field renders as **real checkboxes**: up to six options a plain labelled list, more
than that a **chip picker** (chips for the selection, a search box, a panel of options). `display()`
overrides: `checkboxes`, `tokens`, or `native` for the old select.
2. The picker is **progressive enhancement over the checkboxes**. The checkboxes stay in the page and
remain what the form submits, so keyboard and screen-reader use are the platform's own, a script
failure leaves a usable list, and the stored value is exactly what it was.
3. **No library.** About 120 lines of plain JavaScript and 50 of CSS, no jQuery (rule 5 is about
Composer dependencies, but shipping and scoping a third-party JS widget, and its jQuery
requirement, is the same cost). Chosen is unmaintained; Select2 needs jQuery.
4. The script and style are **printed once in the page footer** by the first picker on the page, from the
library's `resources/` files; two bundled copies of the library may both print them, and an element
is enhanced by whichever runs first.
5. **Every rendering sends an empty marker** (`name[]` with an empty value) first, so "nothing chosen"
arrives and clears the field. Validation ignores empty entries of a multiple field; the stored
format is unchanged (ADR-0016).

## Options considered

- **Keep `<select multiple>` and add Chosen/Select2.** jQuery and a large third-party bundle per
consumer, and it stays a hidden select behind a custom widget.
- **Only a checkbox list.** Fine for six options, unusable for the 100-coin lists real plugins have.
- **A fully custom ARIA combobox.** More code and more accessibility risk than reusing real checkboxes.

## Consequences

- Existing multiple selects change appearance on upgrade; nothing stored changes. `display('native')`
restores the old look.
- A rule on a multiple field applies to each value, not to how many were chosen, so "at most 7" must be
enforced by the consumer. A count rule is a candidate for a later change.
- The `media` field still refers to an admin script that does not exist; unrelated to this change.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,6 @@ Not for a naming choice, a library with no real alternative, or anything you wou
| [0022](0022-shortcodes-are-declared-validated-and-lazy.md) | Shortcodes are declared, validated, access-checked and load assets lazily | Accepted |
| [0023](0023-settings-may-keep-a-legacy-option-prefix.md) | Settings may keep an option prefix that already exists in users' data | Accepted |
| [0024](0024-the-packager-scopes-the-consumers-code-too.md) | The packager scopes the consumer's code and autoloader, not only the library | Accepted |
| [0027](0027-multiple-choice-fields-render-as-checkboxes-with-an-optional-picker.md) | Multiple-choice fields are checkboxes, with an optional chip picker | Accepted |
| [0026](0026-builds-lock-write-atomically-and-clean-up-after-themselves.md) | Builds take a lock, write atomically and clean up after themselves | Accepted |
| [0025](0025-the-guard-can-autoload-the-plugins-own-namespaces.md) | The guard can autoload the plugin's own namespaces (completes ADR-0014) | Accepted |
16 changes: 16 additions & 0 deletions docs/guides/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ Types: `text`, `textarea`, `email`, `url`, `tel`, `number`, `date`, `color`, `hi
the library: implement `FieldType` (or `wp {slug} make:field StarRating`) and
`$fieldTypes->register('stars', new StarRatingFieldType())`.

### Choosing several values

`$f->select('labels', $options)->multiple()` renders as **real checkboxes**, not the browser's
`<select multiple>` (which selects one item per click and cannot be cleared once something is saved):

- up to 6 options: a plain list of labelled checkboxes;
- more: a searchable **chip picker**. Type to filter, Enter picks the first match, Backspace removes the
last chip, Escape closes, every chip has a remove button. It is a small piece of plain JavaScript
(no jQuery, no library) that enhances the checkboxes, so keyboard and screen-reader use work the
same, and without JavaScript the list is still usable.

Choose a look with `->display('checkboxes' | 'tokens' | 'native')`; `native` keeps the old select.
The form always sends an empty marker, so unticking everything is saved as "nothing chosen". What is
stored is unchanged: one serialized array row, as 0.x stored it
([ADR-0027](../adr/0027-multiple-choice-fields-render-as-checkboxes-with-an-optional-picker.md)).

## Meta boxes

```php
Expand Down
8 changes: 8 additions & 0 deletions docs/guides/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ $files = Build::plugin(__DIR__)->dryRun(); // what the
commit to SVN, to mount the built plugin in a test site, or to inspect what ships. The folder is
**replaced**, so it must be for output only: the project itself, a folder above it, or an existing
folder that is neither empty nor an earlier build of this project is refused.
- **Safe to run twice, and to stop.** One build per project runs at a time: a second one fails with who holds
the lock, or waits with `->waitForLock(60)` / `--wait=60`. Output is written under a temporary name
and moved into place when complete, so a failed or stopped build leaves the previous zip (or folder)
whole. Ctrl+C stops the running command and what it started and removes the temporary copy.
([ADR-0026](../adr/0026-builds-lock-write-atomically-and-clean-up-after-themselves.md))
- **Progress.** Every step ends with its duration. On an interactive terminal a step running a quiet
command also shows a spinner; in CI, a pipe, a file, with `NO_COLOR`, `->quiet()`, `->withOutput()`,
`->plain()` or `--plain` you get plain lines only.
- `dryRun()` (`--dry-run`) runs no commands and no verification, so files that steps create (built
assets, `vendor/`) are not in its list. For "what exactly ships", use `toDirectory()`.

Expand Down
2 changes: 2 additions & 0 deletions docs/phases/track-p-packager.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ output as it runs; `"verbose": true` and `Build::verbose()` / `runVerbose()` do
and from a build script. `--quiet` / `Build::quiet()` print only warnings. `--dir=PATH` /
`Build::toDirectory()` (added 2026-10-12) write a folder instead of a zip, with the same steps and
verification; the target is replaced only if it is empty or an earlier build of the same project.
`--plain`, `--wait=N` and the build lock, atomic output, interruption cleanup and the spinner (added
2026-10-12, ADR-0026) complete the robustness items below.

**Configuration** is one optional `wptoolkit.json` at the project root. It's JSON so PHP reads it
without dependencies, and it has a JSON Schema so editors autocomplete it:
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/work-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,10 @@ One dated entry per working session: what landed, what was learned, what's next.
- **Landed:** `Build::toDirectory()` (`--dir`) and `Build::quiet()` (`--quiet`); packaging moved out of `Build` into
`ZipPackager` / `DirectoryPackager`. **Learned:** a folder target compared as a string let `.` and a
different letter case through as "not the project" — paths are now resolved and compared without case.
- **Landed (rc.8):** build lock, atomic zip/directory output, Ctrl+C cleanup with a sweep of abandoned staging, a spinner
with step timings (ADR-0026); multiple-choice fields as checkboxes with a chip picker and an empty marker so a
selection can be cleared (ADR-0027). **Learned:** `proc_open` with a file descriptor lost the child's output
after certain earlier tests in the same PHP process (cause not pinned down); the shell now does the redirect.
The packager tests also run clean on Linux (WSL), including a real SIGINT.
- **Noticed, not fixed:** the `media` field renders `data-wptoolkit-media` for an admin script that does not exist
yet, so its "Choose media" button does nothing.
11 changes: 11 additions & 0 deletions examples/todo/src/Todo.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ public static function fields(FieldFactory $f): array
'high' => __('High', 'wptk-todo'),
'urgent' => __('Urgent', 'wptk-todo'),
])->label(__('Priority', 'wptk-todo'))->default('medium')->required(),
// More than six options, so it renders as the searchable chip picker.
$f->select('labels', [
'home' => __('Home', 'wptk-todo'),
'work' => __('Work', 'wptk-todo'),
'errand' => __('Errand', 'wptk-todo'),
'finance' => __('Finance', 'wptk-todo'),
'health' => __('Health', 'wptk-todo'),
'learning' => __('Learning', 'wptk-todo'),
'travel' => __('Travel', 'wptk-todo'),
'urgent-call' => __('Urgent call', 'wptk-todo'),
])->label(__('Labels', 'wptk-todo'))->multiple(),
$f->select('status', [
'pending' => __('Pending', 'wptk-todo'),
'in_progress' => __('In progress', 'wptk-todo'),
Expand Down
16 changes: 15 additions & 1 deletion languages/wptoolkit.pot
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2026-10-12T08:02:38+00:00\n"
"POT-Creation-Date: 2026-10-12T13:48:18+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: wptoolkit\n"
Expand Down Expand Up @@ -113,6 +113,20 @@ msgstr ""
msgid "Details"
msgstr ""

#: src/Data/Field/Types/ChoiceType.php:152
msgid "Search…"
msgstr ""

#. translators: %s: name of a selected item
#: src/Data/Field/Types/ChoiceType.php:154
#, php-format
msgid "Remove %s"
msgstr ""

#: src/Data/Field/Types/ChoiceType.php:155
msgid "No matches"
msgstr ""

#: src/Data/Field/Types/InputType.php:66
msgid "Saved — leave blank to keep"
msgstr ""
Expand Down
3 changes: 2 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Namespace: `Codad5\WPToolkit` (or your own, if you scope the library). Package:
10. Every user-facing string uses `__()`/`_e()`/`esc_html__()` with the plugin's own text domain, inside `boot()` or later.
11. Declare assets with `AssetManager` (`->script()`, `->style()`); never call `wp_enqueue_*` yourself.
12. Shortcodes: `$shortcodes->add('name', $handler)` with `->public()`/`->loggedIn()`/`->can()`, `->attributes([...])` (same shape as route args), `->view()`, `->assets()`. Handlers return; never echo, and never build `[tag …]` strings from request data — call `$shortcodes->render()`.
13. Change stored data with a `Migration` registered via `->migrations([...])`, never with branches on a "data version".
13. A multiple-choice field (`select` + `->multiple()`) renders as checkboxes, or a chip picker above 6 options; `->display('checkboxes'|'tokens'|'native')` overrides. Do not hand-write a `<select multiple>`.
14. Change stored data with a `Migration` registered via `->migrations([...])`, never with branches on a "data version".

## Core API

Expand Down
12 changes: 10 additions & 2 deletions packages/build/bin/wptoolkit-build
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/**
* wptoolkit-build — package a WordPress plugin or theme from wptoolkit.json (or defaults).
*
* vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet]
* vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet] [--plain] [--wait=SECONDS]
* vendor/bin/wptoolkit-build verify FILE.zip
* vendor/bin/wptoolkit-build doctor [--project=DIR]
* vendor/bin/wptoolkit-build init [--project=DIR]
Expand Down Expand Up @@ -63,6 +63,12 @@ try {
if (in_array('--quiet', $argv, true)) {
$build->quiet();
}
if (in_array('--plain', $argv, true)) {
$build->plain();
}
if (isset($options['wait'])) {
$build->waitForLock((int) $options['wait']);
}
if (isset($options['dir'])) {
$build->toDirectory($options['dir']);
exit(0);
Expand Down Expand Up @@ -108,10 +114,12 @@ try {

default:
fwrite(STDOUT, "Usage:\n"
. " wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet]\n"
. " wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet] [--plain] [--wait=SECONDS]\n"
. " --dir=PATH write a folder instead of a zip (replaced if it is an earlier build)\n"
. " --verbose print the output of every command as it runs\n"
. " --quiet print only warnings and errors\n"
. " --plain no spinner, even on an interactive terminal\n"
. " --wait=N if another build of this project is running, wait up to N seconds\n"
. " wptoolkit-build verify FILE.zip\n"
. " wptoolkit-build doctor [--project=DIR]\n"
. " wptoolkit-build init [--project=DIR]\n");
Expand Down
29 changes: 29 additions & 0 deletions packages/build/src/BackgroundCommandRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/**
* @license GPL-2.0-or-later
*/

declare(strict_types=1);

namespace Codad5\WPToolkit\Build;

use Closure;

/**
* A runner that stays responsive while a command runs: it calls back several times a second, so the
* build can draw a spinner and notice Ctrl+C, and it terminates the command if that callback throws.
*
* A separate interface so existing `CommandRunner` implementations keep working; with one of those
* there is no spinner animation and an interruption is noticed when the command ends.
*/
interface BackgroundCommandRunner extends StreamingCommandRunner
{
/**
* @param Closure(string): void $onLine Each line of stdout or stderr, without the line ending.
* @param Closure(): void $onTick Called several times a second while the command runs. If it
* throws, the command is terminated and the exception is rethrown.
* @return array{exitCode: int, output: string}
*/
public function watch(string $command, string $workingDirectory, Closure $onLine, Closure $onTick): array;
}
Loading
Loading