Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e264b8892 |
@@ -64,4 +64,4 @@ Last thing inspired by Problem Details RFC are extensions. The response can cont
|
||||
|
||||
## Success handling
|
||||
|
||||
Even success must be handled. The user must know that the submission is successfully finished. The form definition can contain a success message as HTML.
|
||||
Even success must be handled. The user must know that the submission is successfully finished. What is shown is a templated HTML code that is defined by user. It can contain custom elements, like `<reservation-summary>`.
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Implementation prompt — Templating module
|
||||
|
||||
> Hand this whole file to a Claude Sonnet model as its task. It is written to be
|
||||
> self-contained but assumes the model can read the repository.
|
||||
|
||||
---
|
||||
|
||||
## Mission
|
||||
|
||||
Implement the templating engine specified in `TEMPLATING.md` as a **new,
|
||||
self-contained module** in this WordPress plugin (Reservair). Today the plugin
|
||||
has two ad-hoc, regex-based templaters; your job is to replace them with one
|
||||
properly-designed module and wire the existing call sites into it.
|
||||
|
||||
Build the smallest thing that satisfies the spec. `TEMPLATING.md` is explicit
|
||||
that the language must stay simple ("Helm being the example of what not to do") —
|
||||
do **not** add conditionals, loops, or expression evaluation. The only language
|
||||
features are `{{ jsonpath }}` interpolation and custom elements.
|
||||
|
||||
## Read these first (in order)
|
||||
|
||||
1. `TEMPLATING.md` — the requirements you are implementing. Treat it as the spec.
|
||||
2. `modules/Database/README.md` and `modules/Logger/README.md` — the **module
|
||||
template** to imitate: a `modules/<Name>/` folder, namespace
|
||||
`Reservair\<Name>`, a `README.md` with *Why / Usage / API reference* sections,
|
||||
`Rsv`-prefixed classes, snake_case method names, a module-specific exception.
|
||||
3. `modules/Database/Db.php` and `modules/Logger/Logger.php` — the coding style
|
||||
to match (PHP 8.0 union types, typed properties, named args, `Logger` for
|
||||
errors, a `\RuntimeException` subclass for failures).
|
||||
4. The two things you are replacing:
|
||||
- `includes/Services/Emails/RsvEmailTemplater.php` — `{{ word }}` → `$data[word]`.
|
||||
- `includes/Services/Forms/RsvFormHtmlRenderer.php::draw_success_template()` —
|
||||
swaps `{{ reservation_summary }}` for a client-filled placeholder.
|
||||
5. `reservair.php::rsv_bootstrap()` — where runtime services and registries are
|
||||
wired on `plugins_loaded`. New registration hooks fire from here.
|
||||
6. `includes/Events/RsvEventDispatcher.php` — the `do_action`/`add_action` hook
|
||||
convention used in this codebase.
|
||||
|
||||
## The module to create
|
||||
|
||||
- Path: `modules/Templating/`
|
||||
- Namespace: `Reservair\Templating` (PSR-4 is already configured in
|
||||
`composer.json`: `"Reservair\\": "modules/"` — no autoload changes needed).
|
||||
- Must include `modules/Templating/README.md` following the Database/Logger
|
||||
README shape.
|
||||
|
||||
## Architecture (a tiny compiler)
|
||||
|
||||
`TEMPLATING.md` describes a compiler with a swappable **frontend**. Honour that:
|
||||
|
||||
```
|
||||
source string ──[ frontend ]──▶ internal node tree ──[ renderer ]──▶ output string
|
||||
RsvHtmlTemplateParser (frontend-agnostic)
|
||||
```
|
||||
|
||||
- **Frontend** `RsvHtmlTemplateParser`: parses HTML into an internal node tree
|
||||
(the "internal structure" from the spec). The renderer must depend only on the
|
||||
node tree, never on HTML — so a future Markdown/other frontend can be dropped in
|
||||
by producing the same tree. Define the parser behind a small interface (e.g.
|
||||
`RsvTemplateParser` with `parse(string $source): RsvTemplateNode`) so the engine
|
||||
takes a frontend by constructor injection and defaults to the HTML one.
|
||||
- **Internal node tree**: a minimal set of node types is enough — e.g. a text
|
||||
node, an interpolation node (holds a JSON Path), and a custom-element node
|
||||
(holds tag name, attribute map, children). Keep it small and explicit.
|
||||
- **Renderer/evaluator**: walks the tree with (a) the submitted-data symbol table
|
||||
and (b) the custom-element registry, and concatenates strings.
|
||||
|
||||
You may use PHP's `DOMDocument` to do the actual HTML tokenizing inside the
|
||||
frontend, but convert its output into your own node tree — do not leak DOM
|
||||
objects into the renderer. Watch the usual `DOMDocument::loadHTML` pitfalls
|
||||
(wrapping `<html>/<body>`, entity handling, UTF-8); strip the synthetic wrappers.
|
||||
|
||||
## Language semantics
|
||||
|
||||
### `{{ jsonpath }}` interpolation
|
||||
- Resolves a JSON Path (RFC 9535) expression against the submitted data array and
|
||||
substitutes the **atomic value** (string/number/bool) as text. Non-scalar
|
||||
results (objects/arrays) resolve to empty string.
|
||||
- Keep the resolver to the minimal subset the use cases need: child access by dot
|
||||
and by bracket, and array index — e.g. `name`, `$.name`, `$.guest.email`,
|
||||
`$.items[0]`. Do **not** implement wildcards, filters, slices, or recursion.
|
||||
- **Use a simple token-walk implementation**, not a real JSON Path parser. Split
|
||||
the path on `.`/`[`/`]` and walk the data array one key at a time:
|
||||
```php
|
||||
function resolve(string $path, array $data): mixed {
|
||||
$tokens = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$current = $data;
|
||||
foreach ($tokens as $token) {
|
||||
if (!isset($current[$token])) return null;
|
||||
$current = $current[$token];
|
||||
}
|
||||
return $current;
|
||||
}
|
||||
```
|
||||
This already handles bare keys, dotted access, and `[index]` in one pass. Make
|
||||
two small additions so the `$`-rooted and quoted-bracket forms resolve: drop a
|
||||
leading `$` token, and strip surrounding `'`/`"` from bracket keys (so
|
||||
`$['guest']` works). A `null` return means "no such path" — render it as the
|
||||
empty string.
|
||||
- **Backward compatibility (required):** existing templates use a bare key,
|
||||
`{{ reservation_summary }}` and email bodies like `{{ name }}`. A bare
|
||||
identifier must resolve as a top-level key, identically to today.
|
||||
- **Security:** the output target is HTML, so HTML-escape interpolated scalar
|
||||
values by default (`esc_html`). This lets you delete the manual
|
||||
`RsvEmailListener::escape_values()` pre-escaping once the engine owns escaping —
|
||||
do that. Custom elements (below) are trusted and return their own markup.
|
||||
|
||||
### Custom elements
|
||||
- A custom element is a registered handler keyed by tag name. The renderer, on
|
||||
meeting `<some-element attr="x">…</some-element>`, builds a **symbol table** and
|
||||
asks the handler to render a string.
|
||||
- Per the spec: the symbol table contains the submitted-data symbols **plus** one
|
||||
symbol per attribute (`<el attr="test">` adds `attr` => `"test"`). Decide and
|
||||
document how children/inner content are exposed (a captured inner string is the
|
||||
simplest; do the simplest thing that the reservation-summary case needs).
|
||||
- Define a small interface, e.g.:
|
||||
```php
|
||||
interface RsvTemplateElement {
|
||||
/** Render this element to a string given its symbol table. */
|
||||
public function render(RsvTemplateSymbols $symbols): string;
|
||||
/** Symbols/attributes this element understands — used by validation. */
|
||||
public function symbols(): array;
|
||||
}
|
||||
```
|
||||
(Adjust names to fit, but keep "has a symbol table, outputs a string".)
|
||||
|
||||
### Registration hook
|
||||
- The module fires `do_action('rsv-template-register-custom-elements', $registry)`
|
||||
so the plugin and any extension can register elements in the handler. Register
|
||||
the core element(s) the same way the plugin registers everything else (from a
|
||||
callback wired in `rsv_bootstrap()`), not by hard-coding them inside the module.
|
||||
|
||||
### Validation
|
||||
- Provide a `validate(string $source): array` (or a small result object) that
|
||||
returns the problems in a template without rendering it: unknown/invalid JSON
|
||||
Path symbols and unregistered custom elements. The spec calls this out as a
|
||||
first-class capability ("The language can be easily validated for symbols
|
||||
existence and validity").
|
||||
|
||||
## Public API (the facade)
|
||||
|
||||
Expose one obvious entry point — e.g. `RsvTemplateEngine` (or `RsvTemplate`):
|
||||
|
||||
```php
|
||||
$engine = new RsvTemplateEngine(); // defaults to the HTML frontend + registry
|
||||
$html = $engine->render($source, $data); // data = submitted values array
|
||||
$errors = $engine->validate($source); // [] when valid
|
||||
```
|
||||
|
||||
Keep method names snake_case to match the other modules. Throw a module
|
||||
exception (e.g. `RsvTemplateException extends \RuntimeException`) for malformed
|
||||
templates, and log via `Reservair\Logger\Logger` where the existing modules do.
|
||||
|
||||
## Integration (wire the new module into the live call sites)
|
||||
|
||||
1. **Email bodies** — replace `RsvEmailTemplater` with the engine.
|
||||
`RsvEmailListener` currently calls `(new RsvEmailTemplater())->render(...)` and
|
||||
pre-escapes values; switch it to the engine and drop the manual escaping (the
|
||||
engine escapes interpolations). Keep `RsvEmailTemplater` only if something else
|
||||
needs it; otherwise remove it and update references.
|
||||
2. **Form success message** — replace the regex in
|
||||
`RsvFormHtmlRenderer::draw_success_template()` with the engine.
|
||||
**Critical caveat:** `reservation_summary` in the success message is **not**
|
||||
server data — it is the visitor's live selection, filled in the browser by
|
||||
`assets/js/forms/RsvFormSender.js`, which looks for the
|
||||
`<div class="rsv-success-summary">` placeholder. So model `reservation-summary`
|
||||
as a registered custom element that, here, emits that exact placeholder div, so
|
||||
the existing client JS keeps working unchanged. Do not try to compute the
|
||||
summary server-side. The admin HTML is still sanitized with `wp_kses_post`
|
||||
before/around templating, as today.
|
||||
3. **Bootstrap** — in `rsv_bootstrap()`, after the form registry block, fire the
|
||||
custom-element registration (or instantiate the engine's registry and register
|
||||
core elements) consistent with how the form element registry is built there.
|
||||
|
||||
## Conventions & constraints (do not skip)
|
||||
|
||||
- **Match module style**, not `includes/` style: `Rsv` class prefix, snake_case
|
||||
methods, typed properties, PHP 8.0 features. Files namespaced `Reservair\Templating`.
|
||||
- **Doc comments state intent, not mechanics** — say *what/why*, never narrate the
|
||||
loop. (This is a standing rule in this repo.)
|
||||
- **No new Composer dependencies** unless genuinely unavoidable; the JSON Path
|
||||
subset and HTML parsing are small enough to implement in-module. If you believe
|
||||
a dependency is warranted, stop and justify it instead of adding it silently.
|
||||
- **No database, no migrations** — this module is pure text transformation.
|
||||
- **Security:** never emit unescaped submitted data; escape interpolations,
|
||||
sanitize admin HTML with `wp_kses_post` at the boundary as today, and treat
|
||||
custom-element output as trusted-by-registration.
|
||||
- Ship `modules/Templating/README.md` (Why / Usage / API reference / Notes), and
|
||||
delete or update `TEMPLATING.md`-superseded code so there is exactly one
|
||||
templating path.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] `modules/Templating/` exists with the engine, HTML frontend, node tree,
|
||||
custom-element registry + interface, JSON Path resolver, validation, a
|
||||
module exception, and `README.md`.
|
||||
- [ ] `rsv-template-register-custom-elements` action is fired by the module and
|
||||
consumed from `rsv_bootstrap()`; `reservation-summary` is registered there.
|
||||
- [ ] Email bodies and form success messages render through the engine; existing
|
||||
templates (`{{ name }}`, `{{ reservation_summary }}`) behave exactly as
|
||||
before, including client-side summary fill.
|
||||
- [ ] `RsvEmailTemplater` and the success-message regex are gone (or justified).
|
||||
- [ ] `composer lint` (phpcs, phpstan, psalm — see `composer.json` scripts) passes
|
||||
clean on the new and changed files.
|
||||
- [ ] README includes at least one worked example for each use case (email,
|
||||
success message) and a custom-element registration example.
|
||||
|
||||
If anything in `TEMPLATING.md` is ambiguous, prefer the **simpler** reading and
|
||||
note the decision in the README rather than expanding the language.
|
||||
@@ -1,19 +1,30 @@
|
||||
.rsv-form-btn {
|
||||
border-radius: 1.375rem;
|
||||
border: 1.5px solid #e0e0e0;
|
||||
color: #555;
|
||||
padding: 0 calc(1.25rem + 4px);
|
||||
height: 3.5rem;
|
||||
background-color: white;
|
||||
|
||||
line-height: 140%;
|
||||
font-size: 1rem;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rsv-form-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Primary CTA (submit / confirm) */
|
||||
.rsv-form-btn-primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
|
||||
border-radius: 1.375rem;
|
||||
font-size: 1rem;
|
||||
padding: 0 calc(1.25rem + 4px);
|
||||
line-height: 140%;
|
||||
height: 3.5rem;
|
||||
|
||||
|
||||
border: none;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: background .12s;
|
||||
@@ -43,12 +54,6 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/*.reservair-form button {
|
||||
padding: var(--s-3) !important;
|
||||
font-weight: 400 !important;
|
||||
}*/
|
||||
|
||||
/*.reservair-form button,*/
|
||||
.rsv-form-input {
|
||||
border: 1px solid var(--color-gray-300);
|
||||
outline: none;
|
||||
@@ -165,47 +170,44 @@
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important;
|
||||
}
|
||||
|
||||
.rsv-success-message {
|
||||
.rsv-success-msg {
|
||||
text-align: center;
|
||||
padding: var(--s-5);
|
||||
color: var(--color-green-700);
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.rsv-success-message p {
|
||||
.rsv-success-msg p {
|
||||
margin-top: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.mesg {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
line-height: 1rem;
|
||||
margin-top: var(--s-5);
|
||||
margin-bottom: var(--s-5);
|
||||
padding: var(--s-4) 0;
|
||||
.rsv-success-msg h1,
|
||||
.rsv-success-msg h2,
|
||||
.rsv-success-msg h3,
|
||||
.rsv-success-msg h4,
|
||||
.rsv-success-msg h5,
|
||||
.rsv-success-msg h6
|
||||
{
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.success-mesg-icon {
|
||||
.rsv-summary {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mesg-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: var(--s-2);
|
||||
.rsv-success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
color: #00000094;
|
||||
background: #dcfce7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-mesg svg {
|
||||
background-color: var(--color-red-200);
|
||||
}
|
||||
|
||||
.success-mesg svg {
|
||||
background-color: rgba(0, 201, 80, 0.36);
|
||||
}
|
||||
/* FORM END */
|
||||
|
||||
.rsv-timetable-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -179,6 +179,14 @@ export const RsvCalendarPicker = (() => {
|
||||
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
|
||||
);
|
||||
},
|
||||
|
||||
// Re-check the radio for the current date. The date lives in JS state, so
|
||||
// this restores the visual selection after a native form.reset() clears it.
|
||||
reselect() {
|
||||
if (this.date === null) return;
|
||||
const radio = this.body.querySelector(`input[id="${this.date.toISOString()}"]`);
|
||||
if (radio) radio.checked = true;
|
||||
},
|
||||
};
|
||||
|
||||
container.classList.add('rsv-calendar');
|
||||
|
||||
@@ -37,6 +37,16 @@ class RsvReservationSelector extends HTMLElement {
|
||||
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
||||
this._slots = [];
|
||||
this._commit();
|
||||
// _commit clears only the slots; keep the picked date selected, re-asserting
|
||||
// it in case a surrounding form.reset() just unchecked the calendar radio.
|
||||
this._calendar?.reselect();
|
||||
}
|
||||
|
||||
// Reset for a new reservation: drop the local selection (keeping the date) and
|
||||
// reload availability so slots booked by the previous submission show as full.
|
||||
reset() {
|
||||
this.clear();
|
||||
this.querySelector('rsv-timeline')?.refresh();
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
@@ -25,6 +25,31 @@ class RsvReservationSummary extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Public API ---------------------------------------------------------
|
||||
|
||||
// Detached, static copy of the current selection for the success message.
|
||||
// Mirrors the live layout minus the interactive "clear all" control.
|
||||
snapshot() {
|
||||
const s = ReservairStrings.summary;
|
||||
const list = this.querySelector('.rsv-summary-list');
|
||||
const count = this.querySelector('.rsv-summary-count');
|
||||
const price = this.querySelector('.rsv-summary-price');
|
||||
|
||||
const node = document.createElement('div');
|
||||
node.className = 'rsv-summary rsv-summary-snapshot';
|
||||
node.innerHTML = `
|
||||
<div class="rsv-summary-header">
|
||||
<span class="rsv-summary-title">${s.title}</span>
|
||||
</div>
|
||||
<ul class="rsv-summary-list">${list ? list.innerHTML : ''}</ul>
|
||||
<div class="rsv-summary-footer">
|
||||
<span class="rsv-summary-count">${count ? count.textContent : ''}</span>
|
||||
<div class="rsv-summary-price">${price ? price.textContent : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_build() {
|
||||
@@ -51,7 +76,6 @@ class RsvReservationSummary extends HTMLElement {
|
||||
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
|
||||
slots.map(s => ({ ...s, price_per_block }))
|
||||
);
|
||||
console.log(all_slots);
|
||||
|
||||
const n = all_slots.length;
|
||||
const list = this.querySelector('.rsv-summary-list');
|
||||
|
||||
@@ -35,6 +35,14 @@ class RsvTimeline extends HTMLElement {
|
||||
this._render();
|
||||
}
|
||||
|
||||
// ---- Public API ---------------------------------------------------------
|
||||
|
||||
// Re-fetch availability for the current date, e.g. after a booking occupied
|
||||
// some slots. The date is unchanged, so attributeChangedCallback won't fire.
|
||||
refresh() {
|
||||
this._render();
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_on_click(event) {
|
||||
@@ -59,10 +67,6 @@ class RsvTimeline extends HTMLElement {
|
||||
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
|
||||
if (v !== this._version) return;
|
||||
|
||||
if(occupancy.length === 0) {
|
||||
this.replaceChildren(this._notice(s.no_blocks));
|
||||
return;
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.classList.add('rsv-slots-label');
|
||||
@@ -70,6 +74,11 @@ class RsvTimeline extends HTMLElement {
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
}).replace(',', '');
|
||||
|
||||
if(occupancy.length === 0) {
|
||||
this.replaceChildren(header, this._notice(s.no_blocks));
|
||||
return;
|
||||
}
|
||||
|
||||
const blocks = [];
|
||||
|
||||
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
|
||||
@@ -107,7 +116,7 @@ class RsvTimeline extends HTMLElement {
|
||||
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
|
||||
cell.dataset.start_utc = from.toISOString();
|
||||
cell.dataset.end_utc = to.toISOString();
|
||||
if (left === 0) cell.classList.add('rsv-slots-slot-full');
|
||||
if (left <= 0) cell.classList.add('rsv-slots-slot-full');
|
||||
|
||||
const time_el = document.createElement('span');
|
||||
time_el.classList.add('rsv-slots-slot-time');
|
||||
|
||||
@@ -56,24 +56,14 @@ export const RsvFormSender = {
|
||||
svg.appendChild(path);
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'success-icon';
|
||||
icon.className = 'rsv-success-icon';
|
||||
icon.appendChild(svg);
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'success-title';
|
||||
title.textContent = s.success_title;
|
||||
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'success-msg';
|
||||
subtitle.textContent = s.success_subtitle;
|
||||
|
||||
const reset_btn = document.createElement('button');
|
||||
reset_btn.className = 'reset-btn';
|
||||
reset_btn.textContent = s.new_reservation;
|
||||
const body = this.build_success_body(form, s);
|
||||
|
||||
const state = document.createElement('div');
|
||||
state.className = 'success-state';
|
||||
state.append(icon, title, subtitle, reset_btn);
|
||||
state.className = 'rsv-success-state';
|
||||
state.append(icon, body);
|
||||
|
||||
const msg = document.createElement('div');
|
||||
msg.appendChild(state);
|
||||
@@ -81,12 +71,50 @@ export const RsvFormSender = {
|
||||
existing.forEach(child => child.style.display = 'none');
|
||||
wrapper.appendChild(msg);
|
||||
|
||||
reset_btn.addEventListener('click', () => {
|
||||
// The form catches every data-rsv-reset button in the card and links it to
|
||||
// its cleanup — so new buttons just need the marker, no wiring here.
|
||||
const reset = () => {
|
||||
msg.remove();
|
||||
form.reset();
|
||||
// Native reset leaves custom controls untouched; reset the reservation
|
||||
// selectors so their slots, hidden inputs and the summary clear, the date
|
||||
// stays selected and availability reloads with the just-booked slots.
|
||||
form.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.reset());
|
||||
this.clear_feedback(form);
|
||||
existing.forEach(child => child.style.display = '');
|
||||
});
|
||||
};
|
||||
state.querySelectorAll('[data-rsv-reset]').forEach(btn => btn.addEventListener('click', reset));
|
||||
},
|
||||
|
||||
// Body of the success card. Uses the admin-configured template when the form
|
||||
// ships one, filling the .rsv-success-summary placeholder (expanded server-side
|
||||
// from <reservation-summary>) with a snapshot of the selected slots; otherwise
|
||||
// falls back to the default text.
|
||||
build_success_body(form, strings) {
|
||||
const tpl = form.parentElement?.querySelector('template.rsv-form-success');
|
||||
|
||||
if (!tpl) {
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'rsv-success-msg';
|
||||
subtitle.textContent = strings.success_subtitle;
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'rsv-success-msg';
|
||||
body.appendChild(tpl.content.cloneNode(true));
|
||||
|
||||
const placeholder = body.querySelector('.rsv-success-summary');
|
||||
if (placeholder) {
|
||||
const summary = form.querySelector('rsv-reservation-summary');
|
||||
if (summary && typeof summary.snapshot === 'function') {
|
||||
placeholder.replaceWith(summary.snapshot());
|
||||
} else {
|
||||
placeholder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
},
|
||||
|
||||
set_loading(form, is_loading) {
|
||||
|
||||
@@ -18,8 +18,9 @@ class RsvFormDefinitionController {
|
||||
'type' => 'object',
|
||||
'required' => false,
|
||||
'properties' => [
|
||||
'email_key' => ['type' => 'string', 'required' => false],
|
||||
'elements' => ['type' => 'array', 'default' => []],
|
||||
'email_key' => ['type' => 'string', 'required' => false],
|
||||
'success_message' => ['type' => 'string', 'required' => false],
|
||||
'elements' => ['type' => 'array', 'default' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
use Reservair\Templating\RsvTemplateEngine;
|
||||
|
||||
/**
|
||||
* Handles all outgoing email for the reservation module.
|
||||
@@ -22,11 +23,7 @@ class RsvEmailListener {
|
||||
<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>
|
||||
<p><strong>Datum:</strong> {{date}}</p>
|
||||
<p><strong>Čas:</strong> {{start}} – {{end}}</p>
|
||||
<p>
|
||||
<a href='{{accept_url}}'>Přijmout</a>
|
||||
|
|
||||
<a href='{{refuse_url}}'>Odmítnout</a>
|
||||
</p>
|
||||
<p><reservation-actions></reservation-actions></p>
|
||||
";
|
||||
|
||||
private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata';
|
||||
@@ -48,17 +45,20 @@ class RsvEmailListener {
|
||||
|
||||
public static function on_pending(RsvTimetableReservationPendingEvent $event): void {
|
||||
try {
|
||||
global $rsv_template_registry;
|
||||
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||
|
||||
$tz = wp_timezone();
|
||||
$start_dt = (clone $event->reservation->start_utc)->setTimezone($tz);
|
||||
$end_dt = (clone $event->reservation->end_utc)->setTimezone($tz);
|
||||
|
||||
$body = (new RsvEmailTemplater())->render(self::DEFAULT_PENDING_BODY, [
|
||||
$body = $engine->render(self::DEFAULT_PENDING_BODY, [
|
||||
'reservation_id' => (string) $event->reservation_id,
|
||||
'date' => esc_html($start_dt->format('Y-m-d')),
|
||||
'start' => esc_html($start_dt->format('H:i')),
|
||||
'end' => esc_html($end_dt->format('H:i')),
|
||||
'accept_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code)),
|
||||
'refuse_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code)),
|
||||
'date' => $start_dt->format('Y-m-d'),
|
||||
'start' => $start_dt->format('H:i'),
|
||||
'end' => $end_dt->format('H:i'),
|
||||
'accept_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code),
|
||||
'refuse_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code),
|
||||
]);
|
||||
|
||||
(new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body);
|
||||
@@ -67,22 +67,6 @@ class RsvEmailListener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-escape scalar form values for safe interpolation into an HTML email
|
||||
* body. Non-scalar values (e.g. nested arrays) are dropped to an empty
|
||||
* string since the templater only substitutes plain placeholders.
|
||||
*
|
||||
* @param array<string,mixed> $values
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private static function escape_values(array $values): array {
|
||||
$escaped = [];
|
||||
foreach ($values as $key => $value) {
|
||||
$escaped[$key] = is_scalar($value) ? esc_html((string) $value) : '';
|
||||
}
|
||||
return $escaped;
|
||||
}
|
||||
|
||||
public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void {
|
||||
try {
|
||||
$form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id);
|
||||
@@ -136,15 +120,14 @@ class RsvEmailListener {
|
||||
$subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject;
|
||||
$body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body;
|
||||
|
||||
$templater = new RsvEmailTemplater();
|
||||
global $rsv_template_registry;
|
||||
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||
|
||||
// Subject is plain text: render with raw values, then strip any tags
|
||||
// or newlines to avoid header issues.
|
||||
$subject = sanitize_text_field($templater->render($subject_tpl, $form_values));
|
||||
// Subject is plain text: render without HTML-escaping, then strip tags/newlines.
|
||||
$subject = sanitize_text_field($engine->render_plain($subject_tpl, $form_values));
|
||||
|
||||
// Body is HTML: escape the user-submitted values before interpolation
|
||||
// so they can't inject markup into the message.
|
||||
$body = $templater->render($body_tpl, self::escape_values($form_values));
|
||||
// Body is HTML: the engine HTML-escapes all interpolated values.
|
||||
$body = $engine->render($body_tpl, $form_values);
|
||||
|
||||
(new RsvEmailSender())->send($user_email, $subject, $body);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -34,7 +34,8 @@ class RsvTimetableReservationRepository {
|
||||
WHERE timetable_id = %d
|
||||
AND `start_utc` < %s
|
||||
AND `end_utc` > %s",
|
||||
[$timetable_id, $end_utc, $start_utc]
|
||||
[$timetable_id, $end_utc->format('Y-m-d H:i:s'), $start_utc->format('Y-m-d H:i:s')],
|
||||
ARRAY_A
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,8 +52,15 @@ function rsv_enqueue_assets(): void {
|
||||
}
|
||||
|
||||
function rsv_enqueue_admin_assets(): void {
|
||||
wp_enqueue_script('rsv-admin', rsv_build_url('admin.js'), [], filemtime(rsv_build_file('admin.js')));
|
||||
wp_enqueue_style('rsv-admin', rsv_build_url('admin.css'), [], filemtime(rsv_build_file('admin.css')));
|
||||
// The client bundle defines the custom elements shared by both front-end and
|
||||
// admin. enqueue_block_assets already enqueues `rsv-client` in the editor, so
|
||||
// re-using the same handle here keeps it loaded exactly once (WP dedupes by
|
||||
// handle) instead of bundling a second copy into admin.js.
|
||||
wp_enqueue_script('rsv-client', rsv_build_url('client.js'), [], filemtime(rsv_build_file('client.js')));
|
||||
wp_enqueue_style('rsv-client', rsv_build_url('client.css'), [], filemtime(rsv_build_file('client.css')));
|
||||
|
||||
rsv_localize_api('rsv-admin');
|
||||
wp_enqueue_script('rsv-admin', rsv_build_url('admin.js'), ['rsv-client'], filemtime(rsv_build_file('admin.js')));
|
||||
wp_enqueue_style('rsv-admin', rsv_build_url('admin.css'), ['rsv-client'], filemtime(rsv_build_file('admin.css')));
|
||||
|
||||
rsv_localize_api('rsv-client');
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
class RsvEmailTemplater {
|
||||
public function render(string $template, array $data) : string {
|
||||
return preg_replace_callback('/{{\s*(\w+)\s*}}/', function($matches) use ($data) {
|
||||
return $data[$matches[1]] ?? '';
|
||||
}, $template);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ class RsvButtonElementHandler implements RsvFormElementHandler {
|
||||
public function draw(RsvFormElementDefinition $element): void {
|
||||
?>
|
||||
<div class="rsv-form-input-group rsv-form-input-short">
|
||||
<button class="rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
||||
<button class="rsv-form-btn rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
|
||||
interface RsvFormElementHandler {
|
||||
function draw(RsvFormElementDefinition $def) : void;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class RsvFormReservationElementHandler implements RsvFormElementHandler {
|
||||
$name = $element->getName();
|
||||
?>
|
||||
<div class="rsv-form-input-group">
|
||||
<label><?= esc_html($element->getLabel()) ?></label>
|
||||
<label class="rsv-form-label"><?= esc_html($element->getLabel()) ?></label>
|
||||
<rsv-reservation-selector
|
||||
timetable-id="<?= $timetable_id ?>"
|
||||
name="<?= esc_attr($name) ?>"
|
||||
|
||||
@@ -7,8 +7,10 @@ class RsvFormDefinition {
|
||||
|
||||
public string $email_key = "";
|
||||
|
||||
public string $success_message = "";
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $definition Full definition array including 'elements' and 'email_key'.
|
||||
* @param array<int,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
|
||||
*/
|
||||
public function __construct(string $id, array $definition) {
|
||||
$this->_elements = [];
|
||||
@@ -19,8 +21,9 @@ class RsvFormDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
$this->_id = $id;
|
||||
$this->email_key = $definition['email_key'] ?? '';
|
||||
$this->_id = $id;
|
||||
$this->email_key = $definition['email_key'] ?? '';
|
||||
$this->success_message = $definition['success_message'] ?? '';
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
@@ -31,6 +34,11 @@ class RsvFormDefinition {
|
||||
return $this->email_key;
|
||||
}
|
||||
|
||||
/** Template shown to the visitor after a successful submission. */
|
||||
public function getSuccessMessage(): string {
|
||||
return $this->success_message;
|
||||
}
|
||||
|
||||
|
||||
public function hasElements() : bool {
|
||||
return count($this->_elements) > 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Templating\RsvTemplateEngine;
|
||||
|
||||
class RsvFormHtmlRenderer {
|
||||
public function draw(RsvFormDefinition $form): bool {
|
||||
@@ -20,12 +21,37 @@ class RsvFormHtmlRenderer {
|
||||
<?php endforeach; ?>
|
||||
|
||||
</form>
|
||||
<?php $this->draw_success_template($form); ?>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the admin-configured success message as an inert <template> that the
|
||||
* client clones once the form is submitted. A <reservation-summary> element
|
||||
* expands to a placeholder div that RsvFormSender fills with the visitor's
|
||||
* selected slots.
|
||||
*/
|
||||
private function draw_success_template(RsvFormDefinition $form): void {
|
||||
$message = trim($form->getSuccessMessage());
|
||||
if ($message === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
global $rsv_template_registry;
|
||||
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||
|
||||
// Sanitize admin HTML before rendering, allowing the registered template
|
||||
// custom elements through so the engine can expand them.
|
||||
$allowed = $rsv_template_registry->kses_allowed(wp_kses_allowed_html('post'));
|
||||
$html = $engine->render(wp_kses($message, $allowed));
|
||||
?>
|
||||
<template class="rsv-form-success"><?= $html ?></template>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function draw_element(RsvFormElementDefinition $data): void {
|
||||
global $rsv_form_registry;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class RsvTimetableReservationService {
|
||||
->get_overlapping_capacity($timetable_id, $start_utc, $end_utc);
|
||||
|
||||
if (count($overlapping_capacity) === 0) {
|
||||
Logger::error("No available capacity for timetable_id: $timetable_id, start_utc: " . $start_utc->format('Y-m-d H:i:s') . ", end_utc: " . $end_utc->format('Y-m-d H:i:s'));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ class RsvTimetableReservationService {
|
||||
$end_min = $this->time_of_day_minutes($end_utc);
|
||||
|
||||
if ((int) $overlapping_capacity[0]->start_time > $start_min) {
|
||||
Logger::error("Overlapping capacity start_time: " . (int) $overlapping_capacity[0]->start_time . " is after the reservation start_time: $start_min");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ class RsvTimetableReservationService {
|
||||
|
||||
$capacity = (int) $overlapping_capacity[0]->capacity;
|
||||
$reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc);
|
||||
error_log('reservations: ' . json_encode($reservations));
|
||||
|
||||
return count($reservations) < $capacity;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.')
|
||||
->text('name', 'Name', '', true, $form_def['name'])
|
||||
->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
|
||||
->textarea('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use <reservation-summary></reservation-summary> to display the selected reservations. Leave blank for the default message.', false, $definition['success_message'] ?? '')
|
||||
->render();
|
||||
?>
|
||||
|
||||
@@ -389,8 +390,9 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
transform: (body) => ({
|
||||
name: body.name,
|
||||
definition: {
|
||||
email_key: body.definition?.email_key ?? '',
|
||||
elements: rsv_elements_source.get_all(),
|
||||
email_key: body.definition?.email_key ?? '',
|
||||
success_message: body.definition?.success_message ?? '',
|
||||
elements: rsv_elements_source.get_all(),
|
||||
},
|
||||
}),
|
||||
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
|
||||
|
||||
@@ -27,7 +27,9 @@ class Db {
|
||||
|
||||
public static function get_results(string $sql, array $params = [], string $output = OBJECT): array {
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results(empty($params) ? $sql : $wpdb->prepare($sql, $params), $output);
|
||||
$query = empty($params) ? $sql : $wpdb->prepare($sql, $params);
|
||||
error_log($query);
|
||||
$rows = $wpdb->get_results($query, $output);
|
||||
self::throw_if_error();
|
||||
return $rows ?? [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating\Elements;
|
||||
|
||||
use Reservair\Templating\RsvTemplateElement;
|
||||
use Reservair\Templating\RsvTemplateSymbols;
|
||||
|
||||
/**
|
||||
* Renders accept / refuse action buttons for a reservation, for the maintainer
|
||||
* approval email. The links come from the template's accept_url and refuse_url
|
||||
* symbols; the button labels can be overridden with the accept-label and
|
||||
* refuse-label attributes.
|
||||
*/
|
||||
class RsvReservationActionsElement implements RsvTemplateElement {
|
||||
|
||||
/** Inline styles, since email clients ignore stylesheets. */
|
||||
private const string ACCEPT_STYLE = 'display:inline-block;padding:10px 18px;margin-right:8px;background:#2e7d32;color:#ffffff;text-decoration:none;border-radius:4px;font-weight:bold;';
|
||||
private const string REFUSE_STYLE = 'display:inline-block;padding:10px 18px;background:#c62828;color:#ffffff;text-decoration:none;border-radius:4px;font-weight:bold;';
|
||||
|
||||
public function render(RsvTemplateSymbols $symbols): string {
|
||||
$accept_url = (string) $symbols->get('accept_url', '');
|
||||
$refuse_url = (string) $symbols->get('refuse_url', '');
|
||||
if ($accept_url === '' && $refuse_url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$accept_label = (string) $symbols->get('accept-label', 'Přijmout');
|
||||
$refuse_label = (string) $symbols->get('refuse-label', 'Odmítnout');
|
||||
|
||||
return '<a href="' . esc_url($accept_url) . '" style="' . self::ACCEPT_STYLE . '">' . esc_html($accept_label) . '</a>'
|
||||
. '<a href="' . esc_url($refuse_url) . '" style="' . self::REFUSE_STYLE . '">' . esc_html($refuse_label) . '</a>';
|
||||
}
|
||||
|
||||
public function symbols(): array {
|
||||
return ['accept-label', 'refuse-label'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating\Elements;
|
||||
|
||||
use Reservair\Templating\RsvTemplateElement;
|
||||
use Reservair\Templating\RsvTemplateSymbols;
|
||||
|
||||
/**
|
||||
* Emits the client-side summary placeholder. The browser-side RsvFormSender
|
||||
* locates this div after form submission and fills it with the visitor's
|
||||
* selected time slots.
|
||||
*/
|
||||
class RsvReservationSummaryElement implements RsvTemplateElement {
|
||||
|
||||
public function render(RsvTemplateSymbols $symbols): string {
|
||||
return '<div class="rsv-success-summary"></div>';
|
||||
}
|
||||
|
||||
public function symbols(): array {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating\Elements;
|
||||
|
||||
use Reservair\Templating\RsvTemplateElement;
|
||||
use Reservair\Templating\RsvTemplateSymbols;
|
||||
|
||||
/**
|
||||
* Renders a button that asks the form to clear itself. It only carries the
|
||||
* data-rsv-reset marker; RsvFormSender finds marked buttons in the success card
|
||||
* and links them to the form's cleanup. The label can be overridden with the
|
||||
* label attribute.
|
||||
*/
|
||||
class RsvResetFormButtonElement implements RsvTemplateElement {
|
||||
|
||||
public function render(RsvTemplateSymbols $symbols): string {
|
||||
$label = (string) $symbols->get('label', 'Odeslat znova');
|
||||
return '<button type="button" class="rsv-form-btn" data-rsv-reset>'
|
||||
. esc_html($label)
|
||||
. '</button>';
|
||||
}
|
||||
|
||||
public function symbols(): array {
|
||||
return ['label'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Templating
|
||||
|
||||
Templates allow to replace place values of symbols in text written in one language. Templating itself requires a language. Therefore the process combines two languages.
|
||||
|
||||
This plugin uses templates for success messages of forms & emails. The usage might expand in the future. That is one of the quality attributes of this requirement.
|
||||
|
||||
## Language
|
||||
|
||||
As both use cases are using HTML (one is an email and another a webpage), the templates in this plugin also uses HTML. The frontend for HTML is `RsvHtmlTemplateParser`, that transforms it into an internal structure. The input language can be changed by writing a new frontend for the compiler in the future.
|
||||
|
||||
The main advantage of HTML is that it can be easily extended with custom elements. But we do note that traditional custom elements using JS cannot be used, because JS would not work like that in emails for example.
|
||||
|
||||
Extensions and the plugin itself can register custom elements to templates, for example `<reservation-summary>` that auto-expands onto a nice summary of the selected time slots.
|
||||
|
||||
Atomic values and strings can be retrieved from submitted data using JSON Path RFC using this syntax: `{{ path }}`. The reason for JSON Path is the simplicity of implementation. We are careful not to overcomplicate the templating language, for a price of being less powerful. Helm language being the example of what not to do.
|
||||
|
||||
The language can be easily validated for symbols existence and validity.
|
||||
|
||||
## Custom elements
|
||||
|
||||
The Templating module calls `rsv-template-register-custom-elements` to register custom elements to templates. Plugins can subscribe to it and in the handler register their own custom elements.
|
||||
|
||||
Custom element has a symbol table with values in the input and outputs string. If the custom element has attributes, like `<custom-element attr="test">`, the symbol `attr` with value `test` will also be added to the symbol table.
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating;
|
||||
|
||||
/** A registered handler for a hyphenated custom element tag. */
|
||||
interface RsvTemplateElement {
|
||||
/** Renders the element to a trusted HTML string given the resolved symbol table. */
|
||||
public function render(RsvTemplateSymbols $symbols): string;
|
||||
|
||||
/**
|
||||
* Returns the symbol names this element understands — used by validation to
|
||||
* report missing or extra attributes.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function symbols(): array;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating;
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
/**
|
||||
* Renders templates in two phases: first it substitutes {{ path }} interpolations
|
||||
* with values from the data, then it walks the HTML and expands the registered
|
||||
* custom elements. The public entry point for the module.
|
||||
*/
|
||||
class RsvTemplateEngine {
|
||||
|
||||
public readonly RsvTemplateRegistry $registry;
|
||||
|
||||
public function __construct(?RsvTemplateRegistry $registry = null) {
|
||||
$this->registry = $registry ?? new RsvTemplateRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders $source as HTML. Interpolated scalar values are HTML-escaped;
|
||||
* custom elements emit their own trusted markup.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function render(string $source, array $data = []): string {
|
||||
try {
|
||||
return $this->expand_elements(
|
||||
$this->interpolate($source, $data, escape: true),
|
||||
$data,
|
||||
);
|
||||
} catch (RsvTemplateException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error($e);
|
||||
throw new RsvTemplateException('Template render failed: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders $source for a plain-text context such as an email subject: values
|
||||
* are substituted without HTML-escaping and all tags are stripped.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function render_plain(string $source, array $data = []): string {
|
||||
return trim(wp_strip_all_tags($this->interpolate($source, $data, escape: false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists a template's problems without rendering it (empty = valid): empty
|
||||
* interpolations, unregistered custom elements, and attributes an element
|
||||
* does not declare.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function validate(string $source): array {
|
||||
$errors = [];
|
||||
|
||||
if (preg_match_all('/{{\s*([^}]*?)\s*}}/', $source, $matches)) {
|
||||
foreach ($matches[1] as $path) {
|
||||
if (trim($path) === '') {
|
||||
$errors[] = 'Empty interpolation: {{ }}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->custom_elements($this->load($source)) as $element) {
|
||||
$handler = $this->registry->get($element->tagName);
|
||||
if ($handler === null) {
|
||||
$errors[] = "Unregistered custom element: <{$element->tagName}>";
|
||||
continue;
|
||||
}
|
||||
$allowed = $handler->symbols();
|
||||
foreach ($this->attributes($element) as $name => $value) {
|
||||
if (!in_array($name, $allowed, true)) {
|
||||
$errors[] = "Unknown attribute \"{$name}\" on <{$element->tagName}>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 1 — interpolation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function interpolate(string $source, array $data, bool $escape): string {
|
||||
return preg_replace_callback('/{{\s*([^}]+?)\s*}}/', function (array $match) use ($data, $escape): string {
|
||||
$value = $this->resolve($match[1], $data);
|
||||
if (!is_scalar($value)) {
|
||||
return ''; // null and non-scalar (arrays/objects) render empty
|
||||
}
|
||||
$string = (string) $value;
|
||||
return $escape ? esc_html($string) : $string;
|
||||
}, $source) ?? $source;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 2 — custom element expansion
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replaces each registered custom element with its handler's output. The
|
||||
* element is swapped for a unique comment marker, the document is serialised,
|
||||
* and the markers are substituted for the (trusted) handler strings — so the
|
||||
* output is never re-escaped by the serialiser.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function expand_elements(string $html, array $data): string {
|
||||
// Skip the DOM round-trip unless a hyphenated tag could match a handler.
|
||||
if ($this->registry->all() === [] || !preg_match('/<[a-z][a-z0-9]*-/i', $html)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$dom = $this->load($html);
|
||||
$nonce = 'rsv-' . bin2hex(random_bytes(6));
|
||||
$replacements = [];
|
||||
|
||||
foreach ($this->custom_elements($dom) as $i => $element) {
|
||||
$handler = $this->registry->get($element->tagName);
|
||||
if ($handler === null) {
|
||||
continue; // leave unknown custom elements in place
|
||||
}
|
||||
$token = "{$nonce}-{$i}";
|
||||
$replacements["<!--{$token}-->"] = $handler->render(
|
||||
new RsvTemplateSymbols(array_merge($data, $this->attributes($element)))
|
||||
);
|
||||
$element->parentNode?->replaceChild($dom->createComment($token), $element);
|
||||
}
|
||||
|
||||
return strtr($this->serialize($dom), $replacements);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JSON Path resolver
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves a minimal JSON Path expression against $data.
|
||||
*
|
||||
* Supported forms: bare key, $.key, $.a.b, $.items[0], $['key'].
|
||||
* Returns null for a missing path; the renderer emits an empty string for null.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function resolve(string $path, array $data): mixed {
|
||||
$tokens = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$current = $data;
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($token === '$') {
|
||||
continue; // root sigil
|
||||
}
|
||||
$token = trim($token, "'\""); // strip bracket-notation quotes
|
||||
if (!is_array($current) || !array_key_exists($token, $current)) {
|
||||
return null;
|
||||
}
|
||||
$current = $current[$token];
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DOM helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function load(string $html): \DOMDocument {
|
||||
$dom = new \DOMDocument();
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
// The XML encoding hint forces UTF-8; NOIMPLIED/NODEFDTD keep the fragment
|
||||
// free of the synthetic <html>/<body>/doctype wrappers libxml adds.
|
||||
$dom->loadHTML(
|
||||
'<?xml encoding="UTF-8">' . $html,
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prev);
|
||||
|
||||
// Drop the encoding hint, which libxml leaves behind as a stray node.
|
||||
foreach (iterator_to_array($dom->childNodes) as $node) {
|
||||
if ($node instanceof \DOMProcessingInstruction
|
||||
|| ($node instanceof \DOMComment && str_contains((string) $node->nodeValue, 'xml encoding'))) {
|
||||
$dom->removeChild($node);
|
||||
}
|
||||
}
|
||||
|
||||
return $dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every hyphenated element in document order — registered or not.
|
||||
*
|
||||
* @return list<\DOMElement>
|
||||
*/
|
||||
private function custom_elements(\DOMDocument $dom): array {
|
||||
$elements = [];
|
||||
foreach ((new \DOMXPath($dom))->query('//*[contains(local-name(), "-")]') as $node) {
|
||||
if ($node instanceof \DOMElement) {
|
||||
$elements[] = $node;
|
||||
}
|
||||
}
|
||||
return $elements;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function attributes(\DOMElement $element): array {
|
||||
$attributes = [];
|
||||
if ($element->hasAttributes()) {
|
||||
foreach ($element->attributes as $attribute) {
|
||||
$attributes[$attribute->name] = $attribute->value;
|
||||
}
|
||||
}
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private function serialize(\DOMDocument $dom): string {
|
||||
$html = '';
|
||||
foreach ($dom->childNodes as $child) {
|
||||
$html .= $dom->saveHTML($child);
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating;
|
||||
|
||||
class RsvTemplateException extends \RuntimeException {}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating;
|
||||
|
||||
/** Holds the mapping from custom element tag names to their handlers. */
|
||||
class RsvTemplateRegistry {
|
||||
/** @var array<string, RsvTemplateElement> */
|
||||
private array $elements = [];
|
||||
|
||||
public function register(string $tag, RsvTemplateElement $element): void {
|
||||
$this->elements[$tag] = $element;
|
||||
}
|
||||
|
||||
public function get(string $tag): ?RsvTemplateElement {
|
||||
return $this->elements[$tag] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<string, RsvTemplateElement> */
|
||||
public function all(): array {
|
||||
return $this->elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends a wp_kses allowlist so the registered custom elements survive
|
||||
* sanitization of admin HTML. Each element contributes its tag and the
|
||||
* attributes it declares via symbols().
|
||||
*
|
||||
* @param array<string, mixed> $base A wp_kses allowed-html map to extend.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function kses_allowed(array $base = []): array {
|
||||
foreach ($this->elements as $tag => $element) {
|
||||
$attributes = [];
|
||||
foreach ($element->symbols() as $name) {
|
||||
$attributes[$name] = true;
|
||||
}
|
||||
$base[$tag] = $attributes;
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Reservair\Templating;
|
||||
|
||||
/** Immutable symbol table passed to custom-element handlers. */
|
||||
class RsvTemplateSymbols {
|
||||
/** @param array<string, mixed> $data */
|
||||
public function __construct(private readonly array $data) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function all(): array {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function get(string $name, mixed $default = null): mixed {
|
||||
return $this->data[$name] ?? $default;
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -1,4 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Templating\Elements\RsvReservationSummaryElement;
|
||||
use Reservair\Templating\Elements\RsvReservationActionsElement;
|
||||
use Reservair\Templating\Elements\RsvResetFormButtonElement;
|
||||
/**
|
||||
* Plugin Name: Reservair
|
||||
* Description: A reservation and booking system for WordPress. Site visitors browse available time slots and submit reservation requests via a Gutenberg block; administrators manage timetables, services, forms, and reservations from the WordPress admin panel.
|
||||
@@ -29,7 +33,7 @@ register_activation_hook( __FILE__, [ 'RsvInstaller', 'install' ] );
|
||||
* plugins we might interact with) is fully loaded.
|
||||
*/
|
||||
function rsv_bootstrap(): void {
|
||||
global $rsv_form_registry;
|
||||
global $rsv_form_registry, $rsv_template_registry;
|
||||
|
||||
// Re-grant the custom capability after a plugin *update* (the activation hook
|
||||
// only runs on activate). No-op once the stored version matches.
|
||||
@@ -47,6 +51,15 @@ function rsv_bootstrap(): void {
|
||||
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
|
||||
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
|
||||
$rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() );
|
||||
|
||||
// Template custom-element registry. Extensions register via the action.
|
||||
add_action( 'rsv-template-register-custom-elements', function ( \Reservair\Templating\RsvTemplateRegistry $reg ): void {
|
||||
$reg->register( 'reservation-summary', new RsvReservationSummaryElement() );
|
||||
$reg->register( 'reservation-actions', new RsvReservationActionsElement() );
|
||||
$reg->register( 'reset-form-button', new RsvResetFormButtonElement() );
|
||||
} );
|
||||
$rsv_template_registry = new \Reservair\Templating\RsvTemplateRegistry();
|
||||
do_action( 'rsv-template-register-custom-elements', $rsv_template_registry );
|
||||
}
|
||||
|
||||
add_action( 'plugins_loaded', 'rsv_bootstrap' );
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import './client.js';
|
||||
|
||||
// The client bundle (custom elements, form sender, client styles) is loaded
|
||||
// separately under the shared `rsv-client` handle — see rsv_enqueue_admin_assets.
|
||||
// Bundling it here too would run customElements.define() twice in the editor.
|
||||
import '../assets/css/RsvAdminStyle.css';
|
||||
|
||||
import { RsvDataGrid } from '../assets/js/elements/RsvDatagrid.js';
|
||||
|
||||
Reference in New Issue
Block a user