diff --git a/README.md b/README.md index 40825bc..ac56675 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,39 @@ -# Nolimit Cache — release feed +# Nolimit Cache -Built zips only. The source lives in `kw/nolimit-cache`, which is private. +![Nolimit Cache — the Overview screen](assets/hero.png) -This repository is **public on purpose**: every WordPress site running the -plugin asks it for updates, and it never sends credentials. A private feed -answers 404 and the update simply never appears — no error on the site, no -entry in the plugins list. +A page cache and front-end optimiser for WordPress and WooCommerce. It answers +a visitor's request from a file on disk before WordPress boots, and it knows +enough about WooCommerce not to do that to a customer holding a basket. WebP +and AVIF copies of your images, Redis object cache, preloading and a set of +health checks are in the same plugin — with **nothing leaving the site**. No +account, no licence check, no telemetry. -Nothing here is edited by hand. Each release is published by the tag job in -the source repository. \ No newline at end of file +## Install + +1. Download `nolimit-cache.zip` from the [latest release](https://gitea.nolimit.dk/kw/nolimit-cache-dist/releases/latest). +2. **Plugins → Add New → Upload Plugin**, then activate. +3. Open **Nolimit → Cache** and let the setup walk you through it — [the setup + guide](docs/SETUP.md) explains what it changes and how to tell it is working. + +Later versions arrive in **Dashboard → Updates** like any other plugin. + +## Documentation + +- [Overview](docs/README.md) — what it does, and what it deliberately does not +- [Installing it, and telling whether it works](docs/SETUP.md) — the settings + that need a decision, images in WebP and AVIF, and how to check the cache is + really serving +- [Hooks and integration](docs/CONTRACT.md) — for developers: what the plugin + offers to other plugins and to your own code + +## More of it + +| | | +|---|---| +| ![Page cache settings](assets/screenshots/page-cache.png) | **Page cache.** How long a page lives, what is never cached whatever you set, and separate copies for phones or signed-in visitors only if you ask for them. | +| ![Images](assets/screenshots/images.png) | **Images.** WebP and AVIF copies written beside your JPEG and PNG files and offered to the browsers that understand them. Originals are never changed. | +| ![WooCommerce settings](assets/screenshots/woocommerce.png) | **WooCommerce.** The basket, checkout and account pages are never cached, and a product page is cleared when its stock or an order changes. | +| ![Tools](assets/screenshots/tools.png) | **Tools.** What it has installed in `wp-content`, written out plainly, and a button to write it again. | + +Requires WordPress 6.0+ and PHP 7.4+. WooCommerce is optional. diff --git a/assets/hero.png b/assets/hero.png new file mode 100644 index 0000000..69ada4e Binary files /dev/null and b/assets/hero.png differ diff --git a/assets/screenshots/images.png b/assets/screenshots/images.png new file mode 100644 index 0000000..440948d Binary files /dev/null and b/assets/screenshots/images.png differ diff --git a/assets/screenshots/page-cache.png b/assets/screenshots/page-cache.png new file mode 100644 index 0000000..947a4dd Binary files /dev/null and b/assets/screenshots/page-cache.png differ diff --git a/assets/screenshots/tools.png b/assets/screenshots/tools.png new file mode 100644 index 0000000..37cf8dd Binary files /dev/null and b/assets/screenshots/tools.png differ diff --git a/assets/screenshots/woocommerce.png b/assets/screenshots/woocommerce.png new file mode 100644 index 0000000..5961214 Binary files /dev/null and b/assets/screenshots/woocommerce.png differ diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md new file mode 100644 index 0000000..b948b54 --- /dev/null +++ b/docs/CONTRACT.md @@ -0,0 +1,91 @@ +# Hooks and integration + +What Nolimit Cache offers to other code, and what it listens to. Written for +developers who want to extend the plugin or sit next to it; nothing here is +needed to use it. + +Hooks prefixed `nlcac_` are this plugin's own and are stable: a rename is a +breaking change and will be called out in the release notes. Hooks prefixed +`wpmgr_` are shared by name with the other Nolimit plugins (see section 2). + +A note on the words: for a filter, `add_filter()` is how you **answer** it, by +supplying a value. The headings below describe the mechanism, who calls +`apply_filters()` and who calls `add_filter()`, because that cannot be misread. + +--- + +## 1. Extension points this plugin OFFERS — `nlcac_*` + +`apply_filters()` / `do_action()` in our code: we run everyone's callbacks. + +| Hook | Type | Signature | Fired from | +|---|---|---|---| +| `nlcac_reason_not_to_cache` | filter | `( string $reason, … )` — return a non-empty string to refuse to cache this request | page cache | +| `nlcac_page_cached` | action | `( string $path )` — a page was just written to disk | page cache | +| `nlcac_purge_urls_for_post` | filter | `( array $urls, int $post_id )` — the URLs about to be purged for a post | purging | +| `nlcac_preload_urls` | filter | `( array $urls )` — the queue the preloader will walk | preloader | +| `nlcac_exclude_script` | filter | `( bool $exclude, string $handle )` — keep a script out of defer | optimiser | +| `nlcac_ai_payload` | filter | `( array $payload )` — what the assistant is about to send | assistant | +| `nlcac_images_skip` | filter | `( bool $skip, string $img_tag )` — return true to keep one `` out of the WebP/AVIF rewrite | images | + +**If this plugin is absent**, none of these fire. A consumer must not assume +its callback ever runs; `has_filter()` is not a substitute for the plugin +being active. + +**A consumer must not** do slow or networked work in +`nlcac_reason_not_to_cache`. It runs on every request, before WordPress, in +the drop-in — which is the whole reason the cache is fast. + +## 2. The family's shared hooks — `wpmgr_*` + +Shared by name across the Nolimit plugins, so **load order decides who wins** +for anything that declares itself once. + +### Offered here — we call `apply_filters()` / `do_action()` + +| Hook | Type | Notes | +|---|---|---| +| `wpmgr_cache_cleared` | action | `( int $removed )`. Fired when the page cache is emptied, so a sibling holding derived state can drop it in the same click. Our own object cache listens to it and only calls `wp_cache_flush()`, so there is no path back into purge. | +| `wpmgr_dashboard_cards` | filter | Provided by the shared menu file, which every Nolimit plugin carries identically. | +| `wpmgr_menu_title` | filter | Same. | +| `wpmgr_menu_glyph` | filter | Same. It exists so the glyph can be overridden. | + +### Answered here — we register a callback with `add_filter()` / `add_action()` + +| Hook | Type | What we do | If nobody offers it | +|---|---|---|---| +| `wpmgr_dashboard_cards` | filter | Add this plugin's card | The card is not drawn; nothing breaks | +| `wpmgr_menu_title` | filter | Answer the shared menu's title | Shared file's default | +| `wpmgr_cache_cleared` | action | Flush our object cache | — (we also fire it) | +| `wpmgr_signals` | filter | Supply this plugin's health signals to a family-wide collection. We answer it; we never read it | Only Nolimit Assistant applies this filter. With it absent or inactive, our signals are contributed to nothing and no error is raised. | +| `wpmgr_setup_recommendations` | filter | Offer three setup items to the Assistant's Setup screen: turn the page cache on, warm it in the background, keep the basket rule on. Every `done` is read from the stored setting, never measured. | Nothing is offered and nothing breaks. Only Nolimit Assistant applies this filter, so without it these items are shown nowhere | + +## 3. WordPress and WooCommerce core + +The plugin also attaches to WordPress and WooCommerce core hooks. They are +listed by area rather than one row each, because none of them is a contract +anyone else needs to honour: they are how the plugin attaches to WordPress. + +| Area | Hooks | +|---|---| +| Boot | `init`, `plugins_loaded` | +| Admin | `admin_init`, `admin_menu`, `admin_notices`, `admin_head`, `admin_footer`, `admin_bar_menu`, `admin_enqueue_scripts`, `plugin_action_links_{file}` | +| Purging | `transition_post_status`, `post_updated`, `deleted_post`, `edit_term`, `delete_term`, `comment_post`, `transition_comment_status`, `update_option_sidebars_widgets`, `shutdown` | +| Front end | `template_redirect`, `wp_head`, `wp_enqueue_scripts`, `script_loader_tag`, `script_loader_src`, `style_loader_src`, `the_content`, `wp_get_attachment_url`, `wp_calculate_image_srcset`, `embed_oembed_discover`, `tiny_mce_plugins` | +| Updates | `site_transient_update_plugins`, `plugins_api`, `upgrader_process_complete` | +| Media | `wp_update_attachment_metadata`, `wp_delete_file` | +| Settings | `update_option_nlcac_settings` | +| Scheduling | `cron_schedules` | +| REST | `rest_api_init` | +| WooCommerce | `woocommerce_new_product`, `woocommerce_update_product`, `woocommerce_order_status_changed`, `woocommerce_product_set_stock`, `woocommerce_product_set_stock_status`, `woocommerce_variation_set_stock`, `woocommerce_variation_set_stock_status`, `wc_after_products_starting_sales`, `wc_after_products_ending_sales` | + +🔴 **`site_transient_update_plugins` is the dangerous one.** The filter name +is built from the update host, so every plugin updating from +gitea.nolimit.dk lands on the same one and WordPress runs every callback for +every plugin. Answering unconditionally offers our zip to a sibling. The +guard is two halves and both are required — register with **3** arguments, +and return `$update` untouched unless `$plugin_file` is ours. Registering +with 2 leaves the argument unset and the guard silently does nothing, which +is the same shape of bug it exists to fix. + +If you write a plugin that updates from the same host, apply the same guard. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c71efb5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,54 @@ +# Nolimit Cache + +A page cache and front-end optimiser for WordPress and WooCommerce. It answers +a visitor's request from a file on disk before WordPress boots, and it knows +enough about WooCommerce not to do that to a customer holding a basket. + +It is one of a family of Nolimit plugins that share an admin menu and a +dashboard. [Hooks and integration](CONTRACT.md) lists what it offers to the +others, and to your own code. + +## What it does + +- **Page cache.** An `advanced-cache.php` drop-in serves a stored copy of a + page. This runs before the theme, the plugins and most of WordPress, which + is where the speed comes from and also why it has to be careful. +- **Object cache.** An optional `object-cache.php` drop-in backed by Redis. +- **Front-end optimisation.** Defers scripts, drops WordPress's emoji and + oEmbed machinery, and can rewrite asset URLs to a CDN. +- **Images.** Writes WebP (and optionally AVIF) copies beside JPEG and PNG + files and offers them with ``, so the page cache stays correct for + every browser. Originals are never touched. +- **Preloading.** Walks a queue of URLs on a five-minute cron so the first + real visitor does not pay for the first render. +- **Purging.** Clears the right pages when a post, a term, a comment or a + WooCommerce product or order changes. +- **Health checks and an assistant.** A set of signals about whether the cache + is actually working, and an ask-box on the admin screens. + +## What it deliberately does not do + +- **No minification or concatenation.** Those break themes in ways that are + expensive to debug and are better handled by a build step. +- **No CSS "critical path" extraction.** Same reason. +- **No telemetry of any kind.** Nothing is reported anywhere. The only + outbound requests are the update check against gitea.nolimit.dk and an AI + provider if, and only if, one has been configured with a key. +- **No account, no licence check, no phone-home.** + +## Where things are + +| | | +|---|---| +| Admin screen | **Nolimit → Cache** | +| Settings | one option, `nlcac_settings` | +| Engine config | `wp-content/cache/nolimit/config.php`, written from the settings | +| Drop-ins | `wp-content/advanced-cache.php`, `wp-content/object-cache.php` | +| REST namespace | `nlcac/v1` | +| Requires | WordPress 6.0, PHP 7.4. WooCommerce optional | +| Version | 0.3.1 | + +## Reading order + +1. [Installing it, and telling whether it works](SETUP.md) +2. [Hooks and integration](CONTRACT.md), for developers diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..9b7a055 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,91 @@ +# Installing it, and telling whether it works + +## Install + +The plugin updates itself from its release repository, but the **first** +install is manual, and on a copy older than 0.2.3 it has to be. + +1. Download `nolimit-cache.zip` from the latest release at + `https://gitea.nolimit.dk/kw/nolimit-cache-dist/releases`. +2. Plugins → Add New → Upload Plugin → Activate. +3. After that, updates appear in the normal update row. + +> **Below 0.2.3, do not trust the update row.** Versions before 0.2.3 could +> offer another Nolimit plugin's zip as their own update. Upgrading *to* +> 0.2.3 or later has to be done by hand for that reason; after that the update +> row is safe. + +## Activation writes two things outside the plugin folder + +- `wp-content/advanced-cache.php` — the drop-in. Without it nothing is served + from cache, however the settings look. +- `WP_CACHE` in `wp-config.php` — WordPress only loads the drop-in when this + constant is true. + +If `wp-config.php` is not writable, the plugin says so on its own screen +rather than failing quietly. That is the single most common reason for "it is +switched on and nothing is cached". + +## The settings that are a decision + +Everything else has a defensible default. These four do not: + +| Setting | Why it needs a decision | +|---|---| +| **Cache lifetime** | How stale a page may be. A shop with frequent price changes wants a short one; a mostly-static site can leave it long, because a change purges the affected pages anyway. | +| **Excluded URIs / cookies** | Anything that must never be served from a shared copy. The WooCommerce basket, checkout and account pages are already excluded and cannot be un-excluded. | +| **Object cache (Redis)** | Off unless there is a Redis to point it at. It replaces `object-cache.php`, so it interacts with anything else that wants that file. | +| **CDN rewriting** | Only if assets are genuinely served from the other host. Wrong here means missing images rather than slow ones. | + +## Images: WebP and AVIF + +**Nolimit → Cache → Images** writes WebP (and, if the server can, AVIF) copies +next to your JPEG and PNG files and offers them to browsers that understand +them. The originals are never touched, so switching it off, or deleting the +copies, leaves the site exactly as it was. + +- Conversion runs in the background; the screen shows how many of the library + are done and how much space was saved. +- If an image changes size or position after switching on, pick the second + delivery option on the same screen. A few themes style images with selectors + that the extra `` element breaks. + +## How to tell it is working + +Do not read the settings page for this. Read these, in order. + +### 1. The hit ratio, on **Nolimit → Cache** + +The dashboard shows a hit ratio over the last 7 days and a count of stored +pages. **Two numbers produced by different routes**: stored pages are counted +by walking the disk, the ratio comes from a counter the engine writes before +WordPress boots. If pages are stored and the ratio says nothing has been +measured, those two disagree and the dashboard will say so explicitly rather +than showing a bare zero. + +That distinction exists because a zero can mean two things, a quiet site or a +broken counter, and the dashboard should never leave you guessing which. + +### 2. The response headers + +``` +curl -sI https://example.test/ | grep -i x-nolimit +``` + +A served-from-cache response carries the plugin's header. A logged-in request +or one with a basket cookie should **not**. + +### 3. The health report + +**Nolimit → Cache → Health** runs each signal and says which failed and why — +the drop-in missing, the cache directory not writable, `WP_CACHE` unset, the +drop-in pointing at a different copy of the plugin. Each is measured by doing +the thing (writing a file, reading the config the engine reads), not by +calling `is_writable()` and believing it. + +## Turning it off + +Deactivating removes the drop-in. If a drop-in is ever left behind — a +half-finished delete, a file permission problem — a site can end up serving +from a cache no plugin is managing. `wp-content/advanced-cache.php` is safe to +delete by hand; WordPress treats its absence as "no page cache".