registry = $registry ?? new RsvTemplateRegistry(); } /** * Renders $source as HTML. Interpolated scalar values are HTML-escaped; * custom elements emit their own trusted markup. * * @param array $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 $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 */ 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 $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 $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[""] = $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 $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 //doctype wrappers libxml adds. $dom->loadHTML( '' . $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 */ 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; } }