You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The settings screen is split into five tabs: GitHub sync, Appearance, Feedback, Access, Uninstall. Each tab is its own settings group, which is not cosmetic: options.php walks the group of the submitted form and calls update_option() for every option in it, with null for the ones the form did not send, so one group across five tabs would empty the four tabs that were not on screen, on every save. A test pins that no option appears in two groups and that every registered option belongs to one, and an unknown tab in the address falls back to the first rather than rendering a form whose save writes nothing.
Handbook → Settings → Appearance has the ten colours that matter and one text size, so a theme that gets it wrong no longer sends people to the Custom CSS field with a list of variable names. The rule the plugin is built on does not change: an empty field means the theme decides, that is the shipped state, and nothing is printed for an empty field. What is set is printed as --lh-user-* on :root, and the stylesheet reads every variable as var(--lh-user-x, , ), which gives three levels in the order of intent: the defaults follow the theme, the fields beat the defaults without a specificity fight, and CSS written by hand beats both because it names --lh-x directly and is printed last. The picker offers the theme's own theme.json palette as swatches, so a site picks a colour the theme already uses; a value that is not a hex colour is dropped rather than repaired, because it ends up inside a style element. Two colours are deliberately not fields: the page-type badge takes the accent, so the three chips under a page stay told apart by colour, and the topic and audience chips have a pair each. The text colour on a filled control is not a field: it is derived from the accent, black or white, whichever has the higher contrast. Text size is a percentage of the plugin's own text, not of a page's content, which belongs to the theme. Every font size in the stylesheet is now a multiple of --lh-base, which stays undeclared and therefore falls back to 1rem: at 100 percent the rendered sizes are identical to before, measured in a browser, and one value moves all 23 of them together and keeps the proportions they were tuned in. That is what a theme like Nodes needs, whose own text is 30.75px while the plugin sizes against 16px and looks small beside it. Eighteen tests, counter-checked on the failures that would be silent: a value that is not a colour getting through, a stylesheet variable renamed out from under a field, a font size left in rem, and two tabs sharing a settings group.
Fixed: the settings a site made were missing on a handbook block rendered outside a handbook page, in a header, a footer or a template part. The Custom CSS was attached to the one place that enqueues the stylesheet on handbook views, not to the stylesheet itself, so the same block was styled one way inside the handbook and another way beside it. Both the settings and the Custom CSS now travel with the handle, like the script data since 0.56.0.
Fixed: deleting the plugin left the no-access page setting behind in the database.
The two page layouts the plugin ships are rearranged, and the navigation ships as an accordion. On a single page the page itself comes first, title then content, and everything about the page follows it in one block at the foot: the feedback prompt, the source note, the badges and the metadata footer. The badges used to sit above the title, where they are the first thing read on a page whose title has not been read yet; the source note was in no shipped template at all, so a GitHub-backed page said nowhere that editing it in WordPress is pointless. Two static separator blocks carry the dividers, so a divider is there whether or not its neighbour renders: a guest without public feedback gets no prompt, a page maintained in WordPress gets no source note, and the foot does not collapse either way. The handbook search moves into the left column under the navigation, because both answer the same question, and both templates ask the navigation for the accordion display: a handbook six levels deep does not fit a 22 percent column as a full menu. An installation that has saved either template in the Site Editor keeps its own version, which is the point of a plugin template; Design → Editor → Templates → Clear customizations brings the plugin's back. BlockTemplatesTest pins what a fresh installation gets, counter-checked on the three failures that say nothing at runtime: an unknown block name, a broken block comment, and a navigation that is not an accordion.
A handbook view no longer costs a database query per page in the handbook. The reader filter runs on the_posts, which WordPress applies before it fills its own caches, so deciding access read every row back from the database twice, once for the post and once for its handbook membership. Both caches are now filled for the whole result set at once, and the membership is read through get_the_terms() instead of wp_get_object_terms(), which bypasses that cache. Measured on a seeded handbook of 2000 pages: the entry page went from 2027 queries and 1.48 seconds to 24 queries and 0.45 seconds, a single page from 2015 queries to 17, the navigation tree from 2009 to 10. The rule is unchanged, only what it costs.
Fixed: a page could end up in two handbooks, and then showed the navigation of a handbook it is not in. The data model says one handbook per page and everything built on it assumes exactly that, but nothing enforced it: the block editor renders the handbooks as a list of checkboxes and lets you tick two. The result was not an error message but a page whose navigation tree does not contain it, whose entry page is the wrong one, and which appears in two handbooks at once. The rule is now enforced where the terms are written: ticking a second handbook moves the page instead of adding to it, because ticking a box is a deliberate act. Which handbook a page belongs to is also answered in one place now (Handbooks::for_post()); the three copies of that expression did not agree, they read the handbooks in name order, so renaming a handbook could move pages from one navigation tree into another. An assignment made before this stays as it is until the page is saved again, resolves to the same handbook everywhere in the meantime, and stays fail-closed for access: every handbook of a page must allow the reader.
Exporting a handbook no longer costs four database queries per page. Each exported page was asked for its terms in the four vocabularies one page at a time, around the cache that the query fetching the pages had already filled. On a handbook of 2000 pages that was 8011 queries and 3.4 seconds, in the request that also has to build the ZIP; it is now 11 queries and 0.3 seconds, and 13 MB less memory. ExportQueryCostTest holds it there, counter-checked: with the old lookup, eight times the pages cost five times the queries and the test fails.
The custom fields the plugin writes follow one rule now, and two of them were in the wrong place. There were three prefixes: living_handbook_ for the editorial fields, lh for the plugin's bookkeeping, and living_handbook for the three feedback keys, left over from an earlier rename. Worse, the two fields that say where a page comes from, living_handbook_source and living_handbook_markdown_source, carried the public prefix, which put them in the Custom Fields box of every handbook page: switching the source from GitHub to WordPress there stops the sync without a word, and the other way round hands a hand-written page to the next sync, which overwrites it. Both are now protected, _lh_source and lh_source_url, as are the feedback counters. The rule is: a field a person fills in is public and named living_handbook, a field the plugin keeps about a page is protected and named lh. Both remain readable and writable over REST, where the permission check is unchanged. Plugin::maybe_upgrade() renames the rows of an existing installation, in order, so an installation from before 0.16.0 arrives at the current name in one run; the affected pages are dropped from the object cache afterwards, because the rows changed underneath it. Four tests cover the renames, that running the upgrade twice changes nothing, that an editorial field and another plugin's key are left alone, and that the moved keys really are protected.
An import that runs into GitHub's request limit now stops instead of carrying on. GitHub allows 60 requests an hour without a login and says with every answer how many are left and when the count resets. Those two headers were never read, so an import that ran out of quota kept going and wrote an error onto every remaining page, leaving a handbook that looks imported and is not. It now stops on a whole page, reports how many pages it managed and when the limit comes back, and tells the screen not to ask again in the meantime. Nothing is lost: starting the import again updates the pages that exist rather than duplicating them. The headers come from api.github.com; the raw file host and the archive download have their own, opaque limits and report nothing, which is one more reason the archive path exists.
Fixed: a link to a page that the import had not created yet was turned into plain text for good. Every page resolves its links the moment it is rendered, and a link with no target becomes text, which is what keeps a handbook free of dead links. During an import that rule fired too early: page one links to page two, page two does not exist yet, so the link was defused, and the closing pass, whose entire purpose is to resolve links once every page is there, found nothing left to resolve. Whether a link survived depended on the order of the work list. While an import is creating pages, an unresolved link is now left exactly as it is, and only the closing pass decides. Covered by a test that imports four pages in a ring, each linking to the next.
The closing pass of an import runs in the same time budget as the import itself. It resolves the links of every imported page, which on a couple of thousand pages is tens of seconds of work, and it used to run in the request that had just spent the full import budget: the one request that has to finish, or the links stay raw. It is now the second phase of the same job and pauses between two pages exactly like the import does. The import screen says what it is doing, "checking the links on 240", instead of appearing to hang after the last page.
An import no longer resolves each internal link with its own database lookups. Every .md link asked for its target by source path, then by slug, and the link text asked for the target's handbooks on top, so the closing pass of a folder import ran thousands of queries in the one request that has to finish for the links to work at all. The handbook is now read into lookup tables once per run, and the pages of the run are cached in one go. Measured on 200 pages with 5 links each: 5807 queries and 3.6 seconds became 3607 queries and 2.4 seconds. What is left is what wp_update_post costs per changed page, about six queries, not the links. Single pages converted on their own keep the old path, where one query beats reading the whole handbook.
Internal: the navigation cache that never was one is out of the code. The version counter was bumped on every change and read by nobody, its docblock promised a cache for later, and uninstall deleted a transient prefix (lh_nav_) that has never once been written. The counter stays, because the area cards cache really does key off it, and it is now documented where it is actually used. Why the navigation is not cached is written down with the measurement instead of left open: its markup carries the current page and its open branches, so a cache would need an entry per page and per viewer, and what the navigation costs is the query behind it, 327 of 470 milliseconds on 2000 pages, which is shared with everything else that reads the handbook.
Internal: the performance work has a measurement now, because optimising without one is guessing. bin/seed-performance.php seeds 2000 pages by default (300 hid exactly this class of bug), sets review dates so all three freshness states really render, defers term counting while it works and takes LH_SEED_RESET=1 to clear a previous run. The new bin/measure-performance.php renders the entry page, a single page and the navigation tree from the plugin's own block templates, cold and warm, and reports queries, time and every query that repeats, which is what makes an N+1 visible. Both are documented in CONTRIBUTING.md. AccessQueryCostTest pins the result: eight times the pages must not cost eight times the queries, and a guest still gets nothing out of a members handbook.
Accessibility: an image or diagram that can be enlarged is now a real button (a diagram's button takes the full column, because a diagram is drawn to the width it is given and would otherwise collapse), so it is reachable with the keyboard, announces what it does and takes focus; the overlay no longer closes when the enlarged picture itself is clicked, and Tab stays inside it, which is what aria-modal already claimed. The page search no longer declares role=combobox with only an Escape key behind it: it is a list of links with arrow-key navigation, Escape to close, and a status line for the number of matches, so the results keep working as links. The result column of a handbook entry is no longer one live region around two dozen cards, which read the whole list again after every keystroke; a status line repeats the count sentence the list already shows.
Corrected from the review: the two Table of Contents landmarks are not both exposed. The mobile and desktop variants are hidden by complementary media queries with display:none, so exactly one is in the accessibility tree at any width. No change was needed.
The blocks now declare their own assets in block.json (editorScript, viewScript, style), so WordPress loads the handbook stylesheet and script exactly where a block is rendered. Before, one place decided from the current query whether this looked like a handbook view, which a block in a template part, a header or a footer is invisible to: such a block was rendered unstyled and without its script. The two shared handles are registered once, with their endpoints and labels attached to the handle rather than to one call site, and the editor bundle is named by the blocks instead of being enqueued on every editor screen from a global hook. A page with none of the plugin's blocks loads nothing.
Internal: the handbook access form is covered by tests, ten of them. This is where the three term meta fields the whole access model reads are written, so the tests pin the closed side: an unknown or missing visibility falls back to members and never to public, a request without a valid nonce or without manage_categories changes nothing, only roles this site has are stored, people are resolved by login or id with unknown ones dropped and duplicates collapsed, and clearing the list clears it. The capability guard is counter-checked: removing it makes the test fail.
Internal: the freshness rule and the page tree are covered by tests, fifteen of them. FreshnessStatus gains status(), the rule without WordPress and without the clock, so the boundaries can be pinned: on the due date a page still counts as reviewed, a second later it is due, and at twice the interval it escalates. A missing or unreadable date says nothing rather than "fine". PageTree is pinned on grouping by parent, ordering by menu order then title, publish only, one handbook at a time, and on the thing that matters most: it is read through the ordinary query, so a guest gets nothing out of a members handbook.
Internal: MarkdownImportPage is covered by tests, twelve of them. The ZIP reading is extracted into read_zip() so its bounds can be tested without a real upload (entry count, per-file and total size, hidden and __MACOSX entries, a file that is not an archive), and the page-writing endpoint is pinned on the decisions that cost content: a re-import matches by source path and updates instead of duplicating, a different path is a different page, a pasted draft never overwrites anything, a slug match stays inside its handbook, a re-import keeps the publication status, the written content is sanitized on this path too, and a contributor cannot take over a page another author published.