Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b3d9f0ece | |||
| 37bced77f4 |
@@ -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.
|
||||||
@@ -87,7 +87,7 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-too-soon):not(.rsv-slots-slot-selected) {
|
.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-selected) {
|
||||||
border-color: #2563eb;
|
border-color: #2563eb;
|
||||||
background: #f5f8ff;
|
background: #f5f8ff;
|
||||||
}
|
}
|
||||||
@@ -115,12 +115,6 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Within minimum lead time — available but not yet bookable */
|
|
||||||
.rsv-slots-slot-too-soon {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selected */
|
/* Selected */
|
||||||
.rsv-slots-slot-selected {
|
.rsv-slots-slot-selected {
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class RsvTimeline extends HTMLElement {
|
|||||||
|
|
||||||
_on_click(event) {
|
_on_click(event) {
|
||||||
const slot = event.target.closest('.rsv-slots-slot');
|
const slot = event.target.closest('.rsv-slots-slot');
|
||||||
if (slot && !slot.classList.contains('rsv-slots-slot-full') && !slot.classList.contains('rsv-slots-slot-too-soon')) {
|
if (slot && !slot.classList.contains('rsv-slots-slot-full')) {
|
||||||
slot.classList.toggle('rsv-slots-slot-selected');
|
slot.classList.toggle('rsv-slots-slot-selected');
|
||||||
slot.dispatchEvent(new Event('input', { bubbles: true }));
|
slot.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ class RsvTimeline extends HTMLElement {
|
|||||||
|
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
|
|
||||||
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ, lead_time_minutes } of occupancy) {
|
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
|
||||||
if (from_minutes === to_minutes || block_occ.length === 0) {
|
if (from_minutes === to_minutes || block_occ.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ class RsvTimeline extends HTMLElement {
|
|||||||
const from_block = parseInt(from_minutes) / block_size_in_minutes;
|
const from_block = parseInt(from_minutes) / block_size_in_minutes;
|
||||||
|
|
||||||
const time_slots = block_occ.map((occ, i) =>
|
const time_slots = block_occ.map((occ, i) =>
|
||||||
this._block(this.date, occ, block_size_in_minutes, from_block + i, lead_time_minutes?.[i] ?? 0)
|
this._block(this.date, occ, block_size_in_minutes, from_block + i)
|
||||||
);
|
);
|
||||||
|
|
||||||
const time_slot_group = document.createElement('div');
|
const time_slot_group = document.createElement('div');
|
||||||
@@ -105,7 +105,7 @@ class RsvTimeline extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_block(date, left, block_size, idx, min_lead_time_minutes = 0) {
|
_block(date, left, block_size, idx) {
|
||||||
const from = new Date(date);
|
const from = new Date(date);
|
||||||
from.setHours(0, idx * block_size, 0, 0);
|
from.setHours(0, idx * block_size, 0, 0);
|
||||||
|
|
||||||
@@ -117,7 +117,6 @@ class RsvTimeline extends HTMLElement {
|
|||||||
cell.dataset.start_utc = from.toISOString();
|
cell.dataset.start_utc = from.toISOString();
|
||||||
cell.dataset.end_utc = to.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');
|
||||||
else if (from < new Date(Date.now() + min_lead_time_minutes * 60_000)) cell.classList.add('rsv-slots-slot-too-soon');
|
|
||||||
|
|
||||||
const time_el = document.createElement('span');
|
const time_el = document.createElement('span');
|
||||||
time_el.classList.add('rsv-slots-slot-time');
|
time_el.classList.add('rsv-slots-slot-time');
|
||||||
|
|||||||
@@ -6,19 +6,16 @@
|
|||||||
class RsvTimetableAvailability {
|
class RsvTimetableAvailability {
|
||||||
/**
|
/**
|
||||||
* @param array<int,int> $occupancy Number of available seats for each time block
|
* @param array<int,int> $occupancy Number of available seats for each time block
|
||||||
* @param array<int,int> $lead_time_minutes Minimum lead time in minutes required for each block
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $from_minutes,
|
public int $from_minutes,
|
||||||
public int $to_minutes,
|
public int $to_minutes,
|
||||||
public int $block_size_in_minutes,
|
public int $block_size_in_minutes,
|
||||||
public array $occupancy,
|
public array $occupancy
|
||||||
public array $lead_time_minutes = []
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
public function push_block(int $capacity, int $min_lead_time_minutes = 0) {
|
public function push_block(int $capacity) {
|
||||||
$this->occupancy[] = $capacity;
|
$this->occupancy[] = $capacity;
|
||||||
$this->lead_time_minutes[] = $min_lead_time_minutes;
|
|
||||||
$this->to_minutes += $this->block_size_in_minutes;
|
$this->to_minutes += $this->block_size_in_minutes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,6 @@ class RsvTimetableReservationService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$max_lead_time = max(array_map(fn($c) => (int) $c->min_lead_time_minutes, $overlapping_capacity));
|
|
||||||
$earliest_allowed = new DateTime('now', new DateTimeZone('UTC'));
|
|
||||||
$earliest_allowed->modify("+{$max_lead_time} minutes");
|
|
||||||
if ($start_utc < $earliest_allowed) {
|
|
||||||
Logger::error("Reservation rejected: minimum lead time of {$max_lead_time} minutes not met for timetable_id: $timetable_id");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$start_min = $this->time_of_day_minutes($start_utc);
|
$start_min = $this->time_of_day_minutes($start_utc);
|
||||||
$end_min = $this->time_of_day_minutes($end_utc);
|
$end_min = $this->time_of_day_minutes($end_utc);
|
||||||
|
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ class RsvTimetableService {
|
|||||||
$availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []);
|
$availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
$max_lead_time = empty($capacity_stack) ? 0 : max(array_map(fn($x) => $x->min_lead_time_minutes, $capacity_stack));
|
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack));
|
||||||
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack), $max_lead_time);
|
|
||||||
} else if($total_capacity === 0 && count($availabilities) !== $availability_idx) {
|
} else if($total_capacity === 0 && count($availabilities) !== $availability_idx) {
|
||||||
$availability_idx++;
|
$availability_idx++;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user