|gcid-…}}, {{var:|gvid-…}}, {{font:heading|body}}, * {{post:title|…}}, {{field:}}, {{link:}} (internal link, URL fields only – E13a). * A placeholder must be the whole value (D9); unknown names fail with the list of existing ones, * labels two entries share fail with their IDs (ruling S13a, DesignSystem::colorIdFor/variableFor). * The result is still checked by VariableRef wherever it is written. Pure PHP. */ final class Placeholders { /** {{post:}} => Divi dynamic content option. */ public const POST = [ 'title' => 'post_title', 'excerpt' => 'post_excerpt', 'date' => 'post_date', 'modified' => 'post_modified_date', 'author' => 'post_author', 'url' => 'post_link_url', 'featured_image' => 'post_featured_image', 'categories' => 'post_categories', 'tags' => 'post_tags', ]; /** Dynamic content that is text and takes before/after settings. */ private const TEXT_SETTINGS = [ 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags', ]; private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/'; /** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */ public function __construct(private DesignSystem $design, private ?LinkTargets $links = null) { } /** * Resolves $value when it is a placeholder; other strings are returned unchanged. * * @throws InvalidArgumentException With a German message for the client. */ public function resolve(string $value): string { if (preg_match(self::PATTERN, trim($value), $match) !== 1) { if (str_contains($value, '{{')) { throw new InvalidArgumentException( 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, ' . 'post, field, link).' ); } return $value; } [, $kind, $name] = $match; return match ($kind) { 'color' => $this->color($name), 'var' => $this->variable($name), 'font' => $this->font($name), 'post' => $this->post($name), 'link' => $this->link($name), default => $this->field($name), }; } /** * Applies resolve() to every string inside $value (full-form values are nested). * * @throws InvalidArgumentException */ public function resolveDeep(mixed $value): mixed { if (is_string($value)) { return $this->resolve($value); } if (is_array($value)) { return array_map([$this, 'resolveDeep'], $value); } return $value; } private function color(string $name): string { $id = $this->design->colorIdFor($name); if ($id === null) { $colors = $this->design->colors(); $known = array_merge( array_keys(array_intersect(DesignSystem::COLOR_ALIASES, array_column($colors, 'id'))), array_column($colors, 'label') ); throw new InvalidArgumentException(sprintf( 'Unbekannte globale Farbe "%s". Vorhanden: %s.', $name, implode(', ', array_unique($known)) )); } return VariableRef::serialize('color', $id, []); } private function variable(string $name): string { $id = $this->design->variableFor($name); if ($id === null) { $labels = array_column($this->design->variables(), 'label'); throw new InvalidArgumentException(sprintf( 'Unbekannte globale Variable "%s". Vorhanden: %s.', $name, $labels === [] ? '(keine)' : implode(', ', $labels) )); } return VariableRef::serialize($this->design->isColorVariable($id) ? 'color' : 'content', $id, []); } private function font(string $name): string { $id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null; if ($id === null) { throw new InvalidArgumentException( sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name) ); } return VariableRef::serialize('content', $id, []); } private function post(string $name): string { $option = self::POST[strtolower($name)] ?? null; if ($option === null) { throw new InvalidArgumentException(sprintf( 'Unbekannter Beitragswert "%s". Vorhanden: %s.', $name, implode(', ', array_keys(self::POST)) )); } $settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : []; return VariableRef::serialize('content', $option, $settings); } /** * {{link:}} → Divi's own internal link post_link_url_ with settings.post_id (F27): * rendered as get_permalink() at view time, so a draft target gets its pretty URL once published. * Whether Divi offers that option (public post types only) VariableRef checks against the registry; * that the value goes into a URL field and the target is readable (S37) the choke point checks again. * * @throws InvalidArgumentException */ private function link(string $name): string { // FILTER_VALIDATE_INT: no id beyond PHP_INT_MAX that would saturate to another entry. $id = preg_match('/^[1-9][0-9]{0,18}$/D', $name) === 1 ? filter_var($name, FILTER_VALIDATE_INT) : false; if (! is_int($id)) { throw new InvalidArgumentException(sprintf( 'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).', $name )); } $type = $this->links?->postTypeOf($id); if ($type === null) { throw new InvalidArgumentException(sprintf( 'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.', $name )); } return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]); } private function field(string $name): string { if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) { throw new InvalidArgumentException(sprintf( 'Ungültiger Feldschlüssel "%s" (erlaubt: Buchstaben, Ziffern, "_" und "-", nicht mit "_" am Anfang).', $name )); } return VariableRef::serialize('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']); } }