feat: Kurzform {{loop:<option>}} in Placeholders – Tabelle LOOP, before/after aus den Loop-Feldern der Komposition (X4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 20:48:51 +02:00
co-authored by Claude Sonnet 5.5
parent 5ee846e63d
commit 6da2c0090f
2 changed files with 136 additions and 6 deletions
+82 -6
View File
@@ -12,7 +12,9 @@ defined('ABSPATH') || exit;
/**
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
* {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a).
* {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a),
* {{loop:<option>}} (a loop option without "loop_", ruling X4 – whether the node sits in a loop of the
* option's kind VariableRef checks against the composition's LoopScope, not this class).
* 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.
@@ -32,16 +34,64 @@ final class Placeholders
'tags' => 'post_tags',
];
/**
* {{loop:<key>}} => Divi loop option (Divi 5.15.1, DynamicContentOption*.php): the key is the option without
* "loop_". The meta key options (loop_*_meta_key_manual_custom_field) are missing on purpose – they need the
* meta key as a setting, so only the full form can write them.
*/
public const LOOP = [
'post_title' => 'loop_post_title',
'post_excerpt' => 'loop_post_excerpt',
'post_date' => 'loop_post_date',
'post_modified_date' => 'loop_post_modified_date',
'post_author' => 'loop_post_author',
'post_author_bio' => 'loop_post_author_bio',
'post_author_profile_picture' => 'loop_post_author_profile_picture',
'post_comment_count' => 'loop_post_comment_count',
'post_featured_image' => 'loop_post_featured_image',
'post_id' => 'loop_post_id',
'post_link' => 'loop_post_link',
'post_terms' => 'loop_post_terms',
'term_name' => 'loop_term_name',
'term_description' => 'loop_term_description',
'term_count' => 'loop_term_count',
'term_permalink' => 'loop_term_permalink',
'term_taxonomy' => 'loop_term_taxonomy',
'term_featured_image' => 'loop_term_featured_image',
'user_name' => 'loop_user_name',
'user_username' => 'loop_user_username',
'user_email' => 'loop_user_email',
'user_avatar' => 'loop_user_avatar',
'user_description' => 'loop_user_description',
'user_url' => 'loop_user_url',
'menu_text' => 'loop_menu_text',
'menu_link' => 'loop_menu_link',
'menu_menu_order' => 'loop_menu_menu_order',
'menu_attr_title' => 'loop_menu_attr_title',
'menu_classes' => 'loop_menu_classes',
'menu_xfn' => 'loop_menu_xfn',
'menu_description' => 'loop_menu_description',
];
/** 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*\}\}$/';
private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link|loop)\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)
{
/**
* @param LinkTargets|null $links Entries {{link:…}} may point to; without them every
* link fails.
* @param array<string, array<string, string>> $loopFields Loop options of the composition's loop scope
* (DynamicContentMap::loopFields): {{loop:…}} writes
* before/after only for options that have them.
*/
public function __construct(
private DesignSystem $design,
private ?LinkTargets $links = null,
private array $loopFields = []
) {
}
/**
@@ -55,7 +105,7 @@ final class Placeholders
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).'
. 'post, field, link, loop).'
);
}
@@ -70,6 +120,7 @@ final class Placeholders
'font' => $this->font($name),
'post' => $this->post($name),
'link' => $this->link($name),
'loop' => $this->loop($name),
default => $this->field($name),
};
}
@@ -154,6 +205,31 @@ final class Placeholders
return VariableRef::serialize('content', $option, $settings);
}
/**
* {{loop:<key>}} → loop_<key> with empty before/after where the scope's loop fields have them, else no
* settings. Outside a loop or in one of another kind the option is not in those fields: the reference is
* written without settings and VariableRef refuses it with the loop message (ruling X5).
*
* @throws InvalidArgumentException
*/
private function loop(string $name): string
{
$option = self::LOOP[strtolower($name)] ?? null;
if ($option === null) {
throw new InvalidArgumentException(sprintf(
'Unbekannter Schleifen-Inhalt "%s". Vorhanden: %s. Den Namen ohne "loop_" angeben '
. '(z. B. {{loop:post_date}}); Schleifen-Felder mit Meta-Schlüssel nur in der Vollform '
. '(lit/divi-dynamic-sources).',
$name,
implode(', ', array_keys(self::LOOP))
));
}
$settings = isset($this->loopFields[$option]['before']) ? ['before' => '', 'after' => ''] : [];
return VariableRef::serialize('content', $option, $settings);
}
/**
* {{link:<ID>}} → Divi's own internal link post_link_url_<type> with settings.post_id (F27):
* rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.