(#3) - templating

This commit was merged in pull request #9.
This commit is contained in:
Martin Slachta
2026-06-14 07:16:13 +02:00
parent f4d3972d07
commit 7d7f748f7a
31 changed files with 661 additions and 126 deletions
+3 -1
View File
@@ -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'];
}
}
+23
View File
@@ -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.
+17
View File
@@ -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;
}
+228
View File
@@ -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;
}
}
+18
View File
@@ -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;
}
}