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:
co-authored by
Claude Sonnet 5.5
parent
5ee846e63d
commit
6da2c0090f
@@ -12,7 +12,9 @@ defined('ABSPATH') || exit;
|
|||||||
/**
|
/**
|
||||||
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
||||||
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
|
* {{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,
|
* 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).
|
* 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.
|
* The result is still checked by VariableRef wherever it is written. Pure PHP.
|
||||||
@@ -32,16 +34,64 @@ final class Placeholders
|
|||||||
'tags' => 'post_tags',
|
'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. */
|
/** Dynamic content that is text and takes before/after settings. */
|
||||||
private const TEXT_SETTINGS = [
|
private const TEXT_SETTINGS = [
|
||||||
'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags',
|
'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, '{{')) {
|
if (str_contains($value, '{{')) {
|
||||||
throw new InvalidArgumentException(
|
throw new InvalidArgumentException(
|
||||||
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
|
'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),
|
'font' => $this->font($name),
|
||||||
'post' => $this->post($name),
|
'post' => $this->post($name),
|
||||||
'link' => $this->link($name),
|
'link' => $this->link($name),
|
||||||
|
'loop' => $this->loop($name),
|
||||||
default => $this->field($name),
|
default => $this->field($name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,6 +205,31 @@ final class Placeholders
|
|||||||
return VariableRef::serialize('content', $option, $settings);
|
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):
|
* {{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.
|
* rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||||
@@ -15,6 +16,15 @@ use PHPUnit\Framework\TestCase;
|
|||||||
|
|
||||||
final class PlaceholdersTest extends TestCase
|
final class PlaceholdersTest extends TestCase
|
||||||
{
|
{
|
||||||
|
/** Loop fields of a post loop as DynamicContentMap::loopFields() gives them (Divi 5.15.1 shapes). */
|
||||||
|
private const POST_LOOP_FIELDS = [
|
||||||
|
'loop_post_date' => [
|
||||||
|
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||||||
|
'date_format' => 'select', 'custom_date_format' => 'text',
|
||||||
|
],
|
||||||
|
'loop_post_featured_image' => ['loop_position' => 'text', 'thumbnail_size' => 'select'],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode
|
* Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode
|
||||||
* refuses colour references by design).
|
* refuses colour references by design).
|
||||||
@@ -78,6 +88,10 @@ final class PlaceholdersTest extends TestCase
|
|||||||
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
|
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
|
||||||
'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
|
'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
|
||||||
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
|
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
|
||||||
|
'embedded loop value' => ['Am {{loop:post_date}}', 'post, field, link, loop'],
|
||||||
|
'unknown loop value' => ['{{loop:views}}', 'Unbekannter Schleifen-Inhalt "views". Vorhanden: post_title,'],
|
||||||
|
'loop value with its prefix' => ['{{loop:loop_post_date}}', 'ohne "loop_"'],
|
||||||
|
'loop meta key option' => ['{{loop:post_meta_key_manual_custom_field}}', 'nur in der Vollform'],
|
||||||
'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
|
'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
|
||||||
'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
|
'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
|
||||||
'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],
|
'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],
|
||||||
@@ -168,4 +182,44 @@ final class PlaceholdersTest extends TestCase
|
|||||||
|
|
||||||
(new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
|
(new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling X4: {{loop:<option>}} names the loop option without its prefix; before/after only where the loop
|
||||||
|
* fields of the composition's scope give the option those settings (Divi has none on image options).
|
||||||
|
*/
|
||||||
|
public function testLoopPlaceholdersNameTheLoopOption(): void
|
||||||
|
{
|
||||||
|
$placeholders = new Placeholders(DiviFixtures::designSystem(), null, self::POST_LOOP_FIELDS);
|
||||||
|
|
||||||
|
$date = $placeholders->resolve('{{ loop : Post_Date }}');
|
||||||
|
self::assertSame(
|
||||||
|
'$variable({"type":"content","value":{"name":"loop_post_date","settings":{"before":"","after":""}}})$',
|
||||||
|
$date
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
'$variable({"type":"content","value":{"name":"loop_post_featured_image","settings":{}}})$',
|
||||||
|
$placeholders->resolve('{{loop:post_featured_image}}')
|
||||||
|
);
|
||||||
|
// Not in the scope's fields (no loop, another kind): no settings – VariableRef names the loop problem.
|
||||||
|
self::assertSame(
|
||||||
|
'$variable({"type":"content","value":{"name":"loop_term_name","settings":{}}})$',
|
||||||
|
$placeholders->resolve('{{loop:term_name}}')
|
||||||
|
);
|
||||||
|
|
||||||
|
// What the placeholder writes passes the checker of a post loop unchanged.
|
||||||
|
$references = new VariableRef(
|
||||||
|
DiviFixtures::designSystem(),
|
||||||
|
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + self::POST_LOOP_FIELDS,
|
||||||
|
LoopScope::ofQueryType('post_types')
|
||||||
|
);
|
||||||
|
self::assertSame($date, $references->canonical($date, ComposeFixture::sanitizer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEveryLoopPlaceholderNamesALoopOption(): void
|
||||||
|
{
|
||||||
|
foreach (Placeholders::LOOP as $short => $option) {
|
||||||
|
self::assertSame('loop_' . $short, $option);
|
||||||
|
self::assertStringNotContainsString('meta_key', $option, 'meta key options need settings (full form)');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user