diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a9e4773b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# Patternslib AGENTS.md instructions + +## Relevant Paths + +- First search all the relevant paths, then - if needed - the individual `node_modules` directories. + +- "@patternslib/patternslib" at `./` is the main Patternslib directory +- "@patternslib/dev" at `../REPOS-pat/dev/` is for base configuration files, e.g. Webpack, prettier, eslint and release-it. +- External Patterns which use this Patternslib infrastructure can be found at: `../REPOS-pat/` +- Plone's "@plone/mockup" repository - the base Plone ClassicUI JavaScript library which uses "@patternslib/patternslib" can be found at: `../../../../plone/patternslib/mockup/` + +## Dev environment tips + +- "@plone/mockup" uses "pnpm" +- "@patternslib/patternslib" and most others use "yarn" (We think of switching to "pnpm") +- All we still Webpack based projects (We think of switching to "rspack" or "vite") +- All use "Jest" as testing framework (We think of switching to "vitest" or "rstest") +- We make heavy use of Module Federation to share libraries between individual Patternslib addons and not to load dependencies twice. +- We use "release-it" for releasing packages on npm. + +### Commit message style + +- We use "conventionalchangelog" changelog generation when releasing. The format is `category(subtitle): commit message`, where category is one of `breaking`, `feat`, `fix`, `maint` or `tech`. Subtitle should define the Pattern or module we were working on, e.g. "pat-inject". The commit message title is a concise summary of what was done. A more elaborate body can be given. + Example: + +``` +feat(pat-inject):‌ Sanitize HTML using DOMPurify + +We're now using DOMPurify to sanitize the HTML response instead of the previous custom regex based sanitization method. This improves the libraries security. +``` + +- If a source commit implements a decision documented in an ADR, reference that ADR in the commit message as (same rule as in AGENTS.md): + +``` +ORIGINAL COMMIT MESSAGE + +Ref: ADR NNNN +``` + +- If a commit was done with significant help from someone else, append this to the commit message: + +``` +ORIGINAL COMMIT MESSAGE + +Co-Authored-By: NAME +``` + +- If a commit was done with significant help from an AI, append this to the commit message: + +``` +ORIGINAL COMMIT MESSAGE + +Assisted-by: NAME +``` + +Don't use `Co-Authored-By` for AI contributions, as for Plone and other projects we need a contributor agreement for commits and an AI cannot sign it. + +- Use these rules in order as they are defined here - first ADR, then scrum, then co-authored, then assisted references. + +- These rules do not apply to build-output commits as defined above. Their commit message must be `Compiled.` + +## Architecture Decision Records + +Record significant decisions made during agentic sessions as Architecture +Decision Records (ADRs) in `./docs/adr/`. + +- Write an ADR when a session makes a non-trivial design, architecture or + styling decision, or a change whose reasoning is not obvious from the + code. Q&A, reviews and trivial fixes need none. If unsure, ask. +- Start with a draft ADR, so that it can be referenced from source commits. +- One ADR per decision, not per session. Write it when the decision is + implemented, before committing. This is not a strict rule. +- File name: `NNNN-short-kebab-title.md`, numbered consecutively. +- Sections: title, `Status: (YYYY-MM-DD)`, + Context, Decision, Consequences. See `./docs/adr/0004-pat-inject-execute-returns-a-promise.md` as an example. +- Unpushed ADRs may be rewritten freely. Once pushed, an accepted ADR is + only changed for typos and clarifications; to revise the decision, write + a new ADR and mark the old one `Superseded by NNNN`. +- If a source commit implements a decision documented in an ADR, reference that ADR in the commit message as: + +``` +ORIGINAL COMMIT MESSAGE + +Ref: ADR NNNN +``` diff --git a/docs/adr/0000-README.md b/docs/adr/0000-README.md new file mode 100644 index 000000000..f9301cba1 --- /dev/null +++ b/docs/adr/0000-README.md @@ -0,0 +1,9 @@ +# Architecture Decision Records + +This directory contains Architecture Decision Records from agentic sessions. + +## References + +- https://www.heise.de/hintergrund/Gut-dokumentiert-Architecture-Decision-Records-4664988.html +- https://martinfowler.com/bliki/ArchitectureDecisionRecord.html +- https://adr.github.io/ diff --git a/docs/adr/0001-pat-reload-instead-of-pat-inject-trigger.md b/docs/adr/0001-pat-reload-instead-of-pat-inject-trigger.md new file mode 100644 index 000000000..bf8728abb --- /dev/null +++ b/docs/adr/0001-pat-reload-instead-of-pat-inject-trigger.md @@ -0,0 +1,70 @@ +# 0001 - Reload content on events with pat-reload instead of a pat-inject trigger + +Status: Accepted (2026-11-09) + +## Context + +A toolbar - a plain `
`, not an `` or `
` - needs to be reloaded +whenever the browser navigates to another URL without a full page load (via +`history.pushState`, e.g. in Plone's folder contents). + +The first approach was a new pat-inject trigger `autoload-navigate`, as +pat-inject is already good at fetching and injecting content. While +implementing and reviewing it, it turned out that it required: + +- changing pat-inject's general contract for this one trigger: + - resolving `url` relative to the current location (appending to it) + instead of like a normal link, + - allowing elements other than `` and ``, if they have a `url`, +- about 70 lines of navigation handling: excluding `history: record` + (which would cause an infinite loop, as recording history fires a + navigation event again), filtering URL changes, cleaning up listeners of + removed elements, debouncing and re-triggering after a running + injection. + +None of that is about injection. It is about _when_ and _from which URL_ to +reload. pat-inject is already the most complex pattern in Patternslib. + +Mockup's `pat-toolbar` shows the same split: it listens to an +application-specific event and builds the URL from application knowledge, +but implements fetching and replacing itself with `$.ajax` - duplicating +what pat-inject does better (source/target selection, error handling, +loading classes, injection events, scanning for new patterns). + +## Decision + +- Remove the `autoload-navigate` trigger from pat-inject again. +- Add a generic pattern `pat-reload`, configured via `data-pat-reload`: + - `url` (required): the URL to reload from. See ADR 0002. + - `trigger-event` (default `navigatesuccess`): the event which triggers + a reload. See ADR 0003. + - `delay` (default `0`): debounce time for successive events. +- pat-reload decides _when_ and _from which URL_ to reload. _What_ is + fetched and _where_ it goes is delegated to pat-inject via its JavaScript + API (`inject.extractConfig` and `inject.execute`) and configured with + `data-pat-inject` on the same element. The element must not have the + `pat-inject` class. +- Unlike pat-inject, which injects into `body` by default, pat-reload + injects into the element itself if no target is configured. +- Events which arrive while a reload is running lead to exactly one more + reload after it has finished, so that the latest state is shown. +- When the element was removed from the DOM, the event listener is removed. +- The names `url` and `trigger-event` were chosen over `reload-view` (Zope + jargon, and the `reload-` prefix is redundant within `data-pat-reload`) + and `event-trigger` (Patternslib already uses `trigger` and + `trigger-element`). + +## Consequences + +- pat-inject's contract stays unchanged. +- pat-reload can be used for anything which needs to be reloaded on a + configurable event - toolbars, breadcrumbs, sidebars, portlets. +- pat-reload depends on pat-inject's JavaScript API, which expects jQuery + objects. To interact with it without jQuery events and data, pat-inject + got a native Promise based API. See ADR 0004. +- pat-push already does "reload this element on an event", but only for + push messages and with its own `fetch` and `innerHTML`. It could be built + upon pat-reload later. +- Events triggered via jQuery's `.trigger()` - like pat-structure's + `structure-url-changed` - cannot be used as `trigger-event`, as native + event listeners don't receive them. diff --git a/docs/adr/0002-pat-reload-url-resolution.md b/docs/adr/0002-pat-reload-url-resolution.md new file mode 100644 index 000000000..49b208c96 --- /dev/null +++ b/docs/adr/0002-pat-reload-url-resolution.md @@ -0,0 +1,60 @@ +# 0002 - pat-reload URL resolution relative to the current location + +Status: Accepted (2026-11-09) + +## Context + +pat-reload (ADR 0001) needs a URL which depends on the current location, e.g. +`/@@render-toolbar` in Plone. + +A fixed URL is wrong after a navigation. Standard relative URL resolution +does not work either: `@@render-toolbar` resolved against `/folder/page` +results in `/folder/@@render-toolbar` - the wrong context. + +When the `navigate` event fires, the new URL is not yet committed. After +`navigatesuccess`, `window.location` is up to date (ADR 0003). + +Hints in the DOM about the actual context - ``, +`link[rel=canonical]`, Plone's `data-base-url` on `` - are not updated +on same-document navigations. After `pushState`, they still describe the +initially loaded page. + +## Decision + +- Relative URLs are appended to the current location: the current URL + without query string and hash, with a trailing slash ensured. So + `@@render-toolbar` on `/folder/page?a=1#x` reloads from + `/folder/page/@@render-toolbar`. +- Absolute URLs - with a scheme like `https:`, starting with `//` or with + `/` - are used as-is. +- Resolution is done with the standard `new URL(url, base)`, which already + distinguishes relative and absolute URLs. No custom check, e.g. "starts + with `http` or `/`", which would miss other schemes and `//`. +- Like in pat-inject, a `#selector` appended to the URL defines the + injection source. +- The base URL is computed in a single method, `get_base_url`, so that it + can be made configurable later. + +## Consequences + +- Relative URLs in pat-reload behave differently from relative URLs in + links. This is documented. +- Plone default pages are not handled yet: there, the actual context is a + content item within the path shown in the URL bar. A configurable base + URL is to be designed separately. Considerations so far: + - An option like `base-url-selector` with a CSS selector, e.g. + `[data-base-url], link[rel=canonical]`. Other standard sources are + `` (reflected in `document.baseURI`); `og:url` is for + social media and not suitable. + - `querySelector` with a selector list returns the first match in + document order, not in the order of the list. Fallbacks would have + to be tried one by one - and selectors can contain commas themselves. + - The URL is stored in different attributes (`href` vs. + `data-base-url`), so the attribute to read needs a rule. + - The DOM hints are stale after same-document navigations. For + navigation events, the new location or the event itself (e.g. + pat-structure's `path`) are the only up-to-date sources - unless the + navigating code updates the hints. +- Changing pat-inject's `url` handling the same way was rejected. It would + have changed the result for every existing relative `url` and `href` + (ADR 0001). diff --git a/docs/adr/0003-use-navigatesuccess-for-navigation-changes.md b/docs/adr/0003-use-navigatesuccess-for-navigation-changes.md new file mode 100644 index 000000000..e4b903b6b --- /dev/null +++ b/docs/adr/0003-use-navigatesuccess-for-navigation-changes.md @@ -0,0 +1,45 @@ +# 0003 - React to navigations on navigatesuccess, not navigate + +Status: Accepted (2026-11-09) + +## Context + +The Navigation API's `navigate` event fires _before_ a navigation is +committed: + +- `window.location` still has the old URL. +- The navigation might still be cancelled. +- It also fires for navigations which unload the page (link clicks to other + pages, form submissions, reloads). Starting a request there is wasted. +- Passing the event on to code which calls `preventDefault()` - like + pat-inject's `onTrigger` - cancels the navigation. + +Patternslib's basic Navigation API polyfill (`src/core/polyfills.js`) for +browsers without the API only fired `navigate`, and - unlike the native +implementation - only _after_ `pushState` and `replaceState` changed the URL. +Native and polyfilled timing differed. + +pat-inject also calls `replaceState` without changing the URL to mark +history entries: `"pageload"` at module load and `"anchor"` on hash +navigations. These fire navigation events too. + +## Decision + +- Code reacting to navigations listens to `navigatesuccess`. Then the + navigation is committed and `window.location` is up to date. +- The polyfill also fires `navigatesuccess` after `pushState` and + `replaceState`. So `navigatesuccess` has the same timing natively and + polyfilled. +- Navigation-triggered reloads only happen if the URL without the hash has + changed. This ignores hash changes and same-URL `replaceState` calls. + +## Consequences + +- pat-reload uses `navigatesuccess` by default (ADR 0001). +- `navigatesuccess` carries no destination; the new URL is read from + `window.location`. +- Back/forward navigations after pat-inject `history: record` injections + trigger a full page reload by pat-inject's `popstate` handler anyway. A + navigation-triggered request might still be started right before that. +- The Navigation API is supported in all major browsers by now. Whether the + polyfill is still needed should be re-evaluated. diff --git a/docs/adr/0004-pat-inject-execute-returns-a-promise.md b/docs/adr/0004-pat-inject-execute-returns-a-promise.md new file mode 100644 index 000000000..bad7021e9 --- /dev/null +++ b/docs/adr/0004-pat-inject-execute-returns-a-promise.md @@ -0,0 +1,71 @@ +# 0004 - pat-inject's execute returns a Promise + +Status: Accepted (2026-11-09) + +## Context + +Code which starts injections via pat-inject's JavaScript API - like +pat-reload (ADR 0001) - needs to know when an injection has finished and +whether it succeeded, e.g. to reload once more after a running injection. + +The injection state was only available via jQuery: + +- pat-ajax triggers `pat-ajax-success` and `pat-ajax-error` with jQuery's + `.trigger()`. Native event listeners - including + `events.add_event_listener` - don't receive jQuery-triggered custom + events. +- The "injection in progress" flag is stored as jQuery data + (`pat-inject-triggered`). + +`execute` started the request and returned `undefined`. Errors were handled +by pat-inject itself (error pages, error message), with no way for the +calling code to react. + +A first step returned a boolean "started" from `execute` and added a native +`pat-inject-finished` event. Listening to an event after calling a method +needs filtering of events bubbling up from nested injections and still does +not allow `async`/`await`. `events.await_event` does not filter by target and +never settles if the event never comes. + +## Decision + +`inject.execute(cfgs, $el)` returns a Promise which settles when the +injection has finished: + +- It resolves to `true` if the content was injected. +- It resolves to `false` if the injection was not started - invalid + configuration, denied confirmation or an injection already in progress - + or if there was nothing to inject. +- It rejects with an `Error` if the request failed with a network error or + an HTTP error status. The error's `cause` is the jqXHR object. + pat-inject's own error handling is done before. +- It also waits for injections with a `process-delay`. + +Additionally, a native `pat-inject-finished` event is dispatched on the +element right before the Promise settles, for code which did not call +`execute` itself. + +Usage: + +```javascript +try { + const injected = await inject.execute(cfgs, $el); +} catch (error) { + log.warn("Could not load.", error); +} +``` + +## Consequences + +- This is a breaking change: code which calls `execute` and ignores the + return value now gets unhandled Promise rejections ("Uncaught (in + promise)") for failed requests. The internal callers in pat-inject, + pat-collapsible and pat-expandable-tree catch them. No callers were found + in Mockup or the add-ons in `REPOS-pat`. +- pat-reload uses `async`/`await` with `try`/`catch` and does not need any + jQuery events or data. It still needs jQuery objects to call pat-inject's + API. +- The Promise only settles if pat-ajax reports the result of the request. + pat-ajax silently dropped results of concurrent requests to the same URL + from different elements, which would leave the Promise unsettled forever. + This was fixed, see ADR 0005. diff --git a/docs/adr/0005-pat-ajax-latest-request-per-element.md b/docs/adr/0005-pat-ajax-latest-request-per-element.md new file mode 100644 index 000000000..d54f2d34c --- /dev/null +++ b/docs/adr/0005-pat-ajax-latest-request-per-element.md @@ -0,0 +1,37 @@ +# 0005 - pat-ajax handles the latest request per element + +Status: Accepted (2026-11-09) + +## Context + +Since 2016, pat-ajax only handles the result of the latest request: "if this +url is requested multiple time, only return the last result". The intention +is that, e.g. for a search form submitted multiple times in a row, an +outdated response does not overwrite a newer one. + +The requests were counted per URL, globally for the whole page. When two +different elements requested the same URL at the same time, the first +element never received a `pat-ajax-success` event: + +- pat-inject's "injection in progress" state of that element was never + reset, so it could never inject again. +- The Promise returned by `inject.execute` (ADR 0004) never settled, so + e.g. pat-reload (ADR 0001) would stop reloading. + +## Decision + +- Requests are counted per element, using a `WeakMap`. +- The latest request of an element wins - regardless of its URL. Counting + per element _and_ URL was rejected, as then an outdated response of the + same element requesting different URLs (e.g. different `formaction` + buttons) could still overwrite a newer one. + +## Consequences + +- Different elements requesting the same URL at the same time each get + their response. +- The original behavior for repeated requests of the same element is kept. +- Errors are still not filtered: a failed outdated request still triggers + `pat-ajax-error` while a newer request is running. Through pat-inject + this cannot happen, as an element cannot have two injections running at + the same time. It only affects bare `.pat-ajax` elements. diff --git a/index.html b/index.html index 80ece4eeb..af739ae7c 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,12 @@

Behavioural

title="Push" >Pushβ
+ Reloadβ