feat: Pods fields in Theme Builder bodies checked against the template's post types (E12)

Both forms Divi writes are recognized (ruling Q11): custom_meta_<key> and the
dialog form post_meta_key + select_meta_key. Choice fields get a note about the
stored value, relation fields name IDs (F4). Only templates the user may edit
are checked or named; others are counted (Q21 pattern). Warnings only, also in
the dry run of a live body. DynamicFields extracted from lit/divi-dynamic-sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 21:02:53 +02:00
co-authored by Claude Opus 5.5
parent a31d4340f9
commit 6be2e65432
15 changed files with 778 additions and 86 deletions
@@ -95,6 +95,20 @@ final class VariableRef
return is_array($settings) ? $settings : [];
}
/** The option name of $value when it is exactly one content reference ("custom_meta_role"); null otherwise. */
public static function nameOf(string $value): ?string
{
try {
$payload = self::payloadOf($value);
} catch (InvalidArgumentException) {
return null;
}
$name = $payload['value']['name'] ?? null;
return ($payload['type'] ?? null) === 'content' && is_string($name) ? $name : null;
}
/** @param array<string, mixed>|stdClass $settings */
public static function serialize(string $type, string $name, array|stdClass $settings): string
{
+4 -2
View File
@@ -20,6 +20,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
@@ -178,7 +179,8 @@ final class DiviIntegration implements Integration
$this->themeBuilderAccess,
new DiviComposition($this->modules, $this->design),
new DiviSaver(),
$this->log
$this->log,
new DynamicFields($this->schema)
);
}
@@ -191,7 +193,7 @@ final class DiviIntegration implements Integration
$this->design,
new DiviComposition($this->modules, $this->design),
new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())),
new ThemeBuilderEdits($this->themeBuilderAccess)
new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema))
);
}
@@ -13,8 +13,6 @@ use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
@@ -27,24 +25,18 @@ defined('ABSPATH') || exit;
*
* Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
* table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
* left out with its reason. Hidden fields (security audit M4) are neither offered nor named. The
* field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
* left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor
* named. The field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
*/
final class DiviDynamicSourcesAbility implements Ability
{
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
private const ID_TYPES = ['relation', 'file'];
/** Candidates the default sample lookup checks for edit_post. */
private const SAMPLE_CANDIDATES = 10;
/** @var Closure(string): int */
private Closure $samplePost;
/** @var Closure(string): ?string */
private Closure $podStorage;
private DynamicFields $fields;
/**
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
@@ -59,7 +51,7 @@ final class DiviDynamicSourcesAbility implements Ability
?Closure $podStorage = null
) {
$this->samplePost = $samplePost ?? self::defaultSample(...);
$this->podStorage = $podStorage ?? self::defaultStorage(...);
$this->fields = new DynamicFields($schema, $podStorage);
}
public function name(): string
@@ -182,7 +174,7 @@ final class DiviDynamicSourcesAbility implements Ability
$fields = [];
$omitted = [];
if ($this->roles->allows('read_dynamic_content_custom_fields')) {
[$fields, $omitted] = $this->fields($postType);
[$fields, $omitted] = $this->fields->offered($postType);
} else {
$notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte '
. 'Felder lesen“ nicht (Divi → Rollen-Editor).';
@@ -215,53 +207,6 @@ final class DiviDynamicSourcesAbility implements Ability
return $type === 'attachment' || str_starts_with($type, '_') ? null : '{{link:<ID>}}';
}
/**
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields.
*/
private function fields(string $postType): array
{
$storage = ($this->podStorage)($postType);
$fields = [];
$omitted = [];
foreach ($this->schema->fields($postType) as $field) {
if ($field->status === FieldDefinition::HIDDEN) {
continue;
}
$entry = ['key' => $field->key, 'label' => $field->label];
$reason = self::omission($field, $storage);
if ($reason !== null) {
$omitted[] = $entry + ['reason' => $reason];
continue;
}
$fields[] = $entry + [
'type' => $field->type,
'source' => $field->source,
'placeholder' => '{{field:' . $field->key . '}}',
];
}
return [$fields, $omitted];
}
private static function omission(FieldDefinition $field, ?string $storage): ?string
{
return match (true) {
! DynamicContentRules::isMetaKey($field->key)
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
$field->source === 'pods' && $storage === 'table'
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
in_array($field->type, self::ID_TYPES, true)
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
$field->type === 'unknown'
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
! in_array($field->type, self::TEXT_TYPES, true)
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
default => null,
};
}
/** The most recently modified entry of the type the current user may edit, else 0. */
private static function defaultSample(string $postType): int
{
@@ -283,19 +228,4 @@ final class DiviDynamicSourcesAbility implements Ability
return 0;
}
private static function defaultStorage(string $postType): ?string
{
if (! function_exists('pods_api')) {
return null;
}
try {
$pod = pods_api()->load_pod(['name' => $postType]);
} catch (Throwable) {
return null;
}
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
}
}
+9 -1
View File
@@ -211,9 +211,17 @@ final class DiviWriter
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
}
// E12: fields in a body checked against the templates' post types – warnings only, also in the dry run.
$fieldWarnings = $this->themeBuilder->fieldWarnings($page, $change);
foreach ($fieldWarnings as $warning) {
$change->warning($warning);
}
$prepared = $this->themeBuilder->prepare($page, $input);
if ($prepared instanceof WP_Error) {
return $prepared;
return $fieldWarnings !== [] && Errors::codeOf($prepared) === 'confirm_required'
? VisibleEdits::merged($prepared, 'Hinweise zu Feldern siehe warnings.', ['warnings' => $fieldWarnings])
: $prepared;
}
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts');
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use Throwable;
defined('ABSPATH') || exit;
/**
* The fields of a post type Divi can show as text through dynamic content (answer F4, E12): custom_meta_<key>
* reads post meta (F25/F26). Relation/file fields show IDs, Pods table storage is no post meta, protected keys
* never (S12), unknown types not (ruling P9) – each omitted with its reason. Choice fields are offered, but
* Divi shows their stored value, not the label (stage3b render: "senior") – note(). Hidden fields (security
* audit M4) are neither offered nor named. Used by lit/divi-dynamic-sources and the Theme Builder body check.
* Not final so abilities can be tested with a mock.
*/
class DynamicFields
{
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
public const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
public const ID_TYPES = ['relation', 'file'];
public const UNKNOWN = 'kein bekanntes Feld dieses Inhaltstyps';
private const PREFIX = 'custom_meta_';
/** Divi's option for a meta key typed in by hand (the key is in settings.meta_key). */
private const MANUAL = 'custom_meta_manual_custom_field_value';
/** @var Closure(string): ?string */
private Closure $podStorage;
/** @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null: none. */
public function __construct(private ContentSchema $schema, ?Closure $podStorage = null)
{
$this->podStorage = $podStorage ?? self::defaultStorage(...);
}
/** @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields. */
public function offered(string $postType): array
{
$storage = ($this->podStorage)($postType);
$fields = [];
$omitted = [];
foreach ($this->schema->fields($postType) as $field) {
if ($field->status === FieldDefinition::HIDDEN) {
continue;
}
$entry = ['key' => $field->key, 'label' => $field->label];
$reason = self::omission($field, $storage);
if ($reason !== null) {
$omitted[] = $entry + ['reason' => $reason];
continue;
}
$fields[] = $entry + [
'type' => $field->type,
'source' => $field->source,
'placeholder' => '{{field:' . $field->key . '}}',
];
}
return [$fields, $omitted];
}
/** Why {{field:$key}} would not show as text for $postType; null when it does. */
public function reason(string $postType, string $key): ?string
{
[$fields, $omitted] = $this->offered($postType);
foreach ($fields as $field) {
if ($field['key'] === $key) {
return null;
}
}
foreach ($omitted as $field) {
if ($field['key'] === $key) {
return $field['reason'];
}
}
return self::UNKNOWN;
}
/** What to know about an offered field of $postType ($key) that shows other text than expected; else null. */
public function note(string $postType, string $key): ?string
{
[$fields] = $this->offered($postType);
foreach ($fields as $field) {
if ($field['key'] === $key && $field['type'] === 'choice') {
return 'Auswahlfeld – Divi zeigt den gespeicherten Wert, nicht die Beschriftung';
}
}
return null;
}
/**
* The meta key the content reference $value reads, in both forms Divi resolves (ruling Q11, stage3b README
* finding 12; DynamicContentOptionPostMetaKey.php:222-262): the name custom_meta_<key> (the plugin's form)
* and the name post_meta_key with select_meta_key "custom_meta_<key>" (Divi's dialog), each also with a key
* typed in by hand (custom_meta_manual_custom_field_value or post_meta_key with only meta_key). Null for
* anything else. Reads, never validates – writing goes through VariableRef::canonical() (S29/S30).
*/
public static function metaKeyOf(string $value): ?string
{
if (VariableRef::typeOf($value) !== 'content') {
return null;
}
$name = VariableRef::nameOf($value);
$settings = VariableRef::settingsOf($value);
$manual = is_string($settings['meta_key'] ?? null) ? $settings['meta_key'] : '';
if ($name === 'post_meta_key') {
$selected = $settings['select_meta_key'] ?? '';
if (! is_string($selected)) {
return null;
}
if ($selected === '') {
return $manual !== '' ? $manual : null;
}
$name = $selected;
}
if ($name === null || ! str_starts_with($name, self::PREFIX)) {
return null;
}
if ($name === self::MANUAL) {
return $manual !== '' ? $manual : null;
}
$key = str_replace(self::PREFIX, '', $name);
return $key !== '' ? $key : null;
}
private static function omission(FieldDefinition $field, ?string $storage): ?string
{
return match (true) {
! DynamicContentRules::isMetaKey($field->key)
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
$field->source === 'pods' && $storage === 'table'
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
in_array($field->type, self::ID_TYPES, true)
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
$field->type === 'unknown'
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
! in_array($field->type, self::TEXT_TYPES, true)
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
default => null,
};
}
private static function defaultStorage(string $postType): ?string
{
if (! function_exists('pods_api')) {
return null;
}
try {
$pod = pods_api()->load_pod(['name' => $postType]);
} catch (Throwable) {
return null;
}
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
}
}
@@ -48,7 +48,8 @@ final class TbCreateTemplateAbility implements Ability
private TemplateConditions $conditions,
private DiviComposition $composition,
private DiviSaver $saver,
private ChangeLog $log
private ChangeLog $log,
private ?ThemeBuilderEdits $edits = null
) {
}
@@ -286,6 +287,14 @@ final class TbCreateTemplateAbility implements Ability
SaveScope::ThemeBuilderLayout
)
);
if ($area === 'body' && $this->edits !== null) {
// E12: fields of the new body against the post types of the new conditions – warnings only.
$warnings = array_merge($warnings, $this->edits->checkFields(
$layout['blocks'],
Template::postTypesOf($useOn),
'Das neue Body-Layout'
));
}
}
$themeBuilderId = $build->themeBuilder();
@@ -92,9 +92,18 @@ final class Template
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
public function postTypes(): array
{
return self::postTypesOf($this->useOn);
}
/**
* @param list<string> $useOn
* @return list<string>
*/
public static function postTypesOf(array $useOn): array
{
$types = [];
foreach ($this->useOn as $condition) {
foreach ($useOn as $condition) {
$parts = explode(':', $condition);
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
$types[] = $parts[2];
@@ -6,7 +6,10 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use WP_Error;
@@ -22,12 +25,17 @@ defined('ABSPATH') || exit;
* change (VisibleEdits, ruling Q2): a backup when revisions are off, refused when it could not be faithful (P9).
* Layouts of disabled or detached templates save at once. The save scope sets the Divi flags and clears every
* page's CSS and the Theme Builder cache.
*
* Fields in a body (E12, answer F4): the field references of the changed nodes – both forms Divi writes (ruling
* Q11) – are checked against the post types of the templates that use the body; warnings only, nothing is
* refused. Templates the user may not edit are only counted (ruling Q21 pattern). Read-only analysis.
*/
final class ThemeBuilderEdits
{
public function __construct(
private ThemeBuilderAccess $access,
private VisibleEdits $visible = new VisibleEdits()
private VisibleEdits $visible = new VisibleEdits(),
private ?DynamicFields $fields = null
) {
}
@@ -99,6 +107,160 @@ final class ThemeBuilderEdits
);
}
/**
* E12: in a body layout, the field references of the changed nodes checked against the post types of the
* templates that use the layout (their singular conditions). Only templates the user may edit are named or
* checked; others add a count (ruling Q21 pattern). Warnings only – the layout is saved anyway.
*
* @return list<string>
*/
public function fieldWarnings(DiviPage $page, DiviChange $change): array
{
if ($this->fields === null || $page->themeBuilderArea() !== 'body') {
return [];
}
$keys = self::keysIn($change->touched());
if ($keys === []) {
return [];
}
$id = (int) $page->post->ID;
$uses = static fn (Template $template): bool => $template->layouts['body']['id'] === $id;
$live = $this->access->editableLiveTemplates();
$types = [];
foreach (array_filter($live['templates'], $uses) as $template) {
array_push($types, ...$template->postTypes());
}
$types = array_values(array_unique($types));
$hidden = count(array_filter($live['hidden'], $uses));
if ($types === [] && $hidden > 0) {
return [sprintf(
'Body-Layout %d nutzen nur Vorlagen ohne Berechtigung (%d) oder keine mit Bedingung '
. '„singular:post_type:…“; ob die Felder %s dort existieren, ist ungeprüft.',
$id,
$hidden,
implode(', ', $keys)
)];
}
$warnings = $this->checkKeys($keys, $types, sprintf('Body-Layout %d', $id));
if ($hidden > 0) {
$warnings[] = sprintf(
'Weitere Vorlagen ohne Berechtigung nutzen dieses Layout: %d; ob die Felder %s für deren '
. 'Inhaltstypen passen, ist ungeprüft.',
$hidden,
implode(', ', $keys)
);
}
return $warnings;
}
/**
* Field references ({{field:<key>}} = custom_meta_<key>, and Divi's dialog form post_meta_key with
* select_meta_key – ruling Q11) in $blocks and their descendants against $postTypes.
*
* @param list<Block> $blocks
* @param list<string> $postTypes
* @param string $what Names the layout in the warning ("Das neue Body-Layout").
* @return list<string>
*/
public function checkFields(array $blocks, array $postTypes, string $what): array
{
if ($this->fields === null) {
return [];
}
$keys = self::keysIn($blocks);
return $keys === [] ? [] : $this->checkKeys($keys, $postTypes, $what);
}
/**
* @param list<string> $keys
* @param list<string> $postTypes
* @return list<string>
*/
private function checkKeys(array $keys, array $postTypes, string $what): array
{
if ($this->fields === null) {
return [];
}
if ($postTypes === []) {
return [sprintf(
'%s gehört zu keiner Vorlage mit Bedingung „singular:post_type:…“; ob die Felder %s dort existieren, '
. 'ist ungeprüft (Bedingungen mit lit/tb-assign setzen).',
$what,
implode(', ', $keys)
)];
}
$warnings = [];
foreach ($keys as $key) {
$problems = [];
$notes = [];
foreach ($postTypes as $type) {
$reason = $this->fields->reason($type, $key);
if ($reason !== null) {
$problems[] = $type . ': ' . $reason;
continue;
}
$note = $this->fields->note($type, $key);
if ($note !== null) {
$notes[] = $type . ': ' . $note;
}
}
if ($problems !== []) {
$warnings[] = sprintf(
'Feld „%s“ zeigt Divi womöglich nicht als Text (%s); passende Felder mit lit/divi-dynamic-sources.',
$key,
implode('; ', array_merge($problems, $notes))
);
} elseif ($notes !== []) {
$warnings[] = sprintf('Feld „%s“: %s.', $key, implode('; ', $notes));
}
}
return $warnings;
}
/**
* @param list<Block> $blocks
* @return list<string>
*/
private static function keysIn(array $blocks): array
{
$keys = [];
foreach ($blocks as $block) {
self::collectKeys($block, $keys);
}
return array_values(array_unique($keys));
}
/** @param list<string> $keys */
private static function collectKeys(Block $block, array &$keys): void
{
if ($block->isOpaque()) {
return;
}
$attrs = $block->attrs();
array_walk_recursive($attrs, static function (mixed $value) use (&$keys): void {
$key = is_string($value) ? DynamicFields::metaKeyOf($value) : null;
if ($key !== null) {
$keys[] = $key;
}
});
foreach ($block->children() as $child) {
self::collectKeys($child, $keys);
}
}
/** @param list<Template> $templates */
private static function count(array $templates): string
{
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration;
@@ -26,7 +27,8 @@ final class ThemeBuilderIntegration implements Integration
private ThemeBuilderAccess $access,
private DiviComposition $composition,
private DiviSaver $saver,
private ChangeLog $log
private ChangeLog $log,
private DynamicFields $fields
) {
}
@@ -51,7 +53,14 @@ final class ThemeBuilderIntegration implements Integration
return [
new TbListAbility($this->access, $conditions),
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
new TbCreateTemplateAbility(
$this->access,
$conditions,
$this->composition,
$this->saver,
$this->log,
new ThemeBuilderEdits($this->access, fields: $this->fields)
),
new TbAssignAbility($this->access, $conditions, $this->log),
new TbActivateAbility($this->access, $conditions, $this->log),
];