251 lines
9.1 KiB
PHP
251 lines
9.1 KiB
PHP
<?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, references to unknown symbols, unregistered custom
|
|
* elements, and attributes an element does not declare.
|
|
*
|
|
* @param list<string>|null $symbols When given, the roots an interpolation may
|
|
* reference; a path rooted outside the set is reported as unknown. Null
|
|
* skips the reference check (any path is accepted).
|
|
* @return list<string>
|
|
*/
|
|
public function validate(string $source, ?array $symbols = null): array {
|
|
$errors = [];
|
|
|
|
if (preg_match_all('/{{\s*([^}]*?)\s*}}/', $source, $matches)) {
|
|
foreach ($matches[1] as $path) {
|
|
$expr = trim($path);
|
|
if ($expr === '') {
|
|
$errors[] = 'Empty interpolation: {{ }}';
|
|
continue;
|
|
}
|
|
$root = $this->tokens($expr)[0] ?? null;
|
|
if ($symbols !== null && $root !== null && !in_array($root, $symbols, true)) {
|
|
$errors[] = "Unknown reference: {{ {$expr} }}";
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
$current = $data;
|
|
|
|
foreach ($this->tokens($path) as $token) {
|
|
if (!is_array($current) || !array_key_exists($token, $current)) {
|
|
return null;
|
|
}
|
|
$current = $current[$token];
|
|
}
|
|
|
|
return $current;
|
|
}
|
|
|
|
/**
|
|
* Splits a JSON Path into its key tokens, dropping the root sigil and
|
|
* bracket-notation quotes — e.g. "$.items[0]" yields ['items', '0'].
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function tokens(string $path): array {
|
|
$raw = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
$tokens = [];
|
|
foreach ($raw as $token) {
|
|
if ($token === '$') {
|
|
continue; // root sigil
|
|
}
|
|
$tokens[] = trim($token, "'\""); // strip bracket-notation quotes
|
|
}
|
|
return $tokens;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
}
|