fix: Theme Builder minors of the final review (M-4, M-5, M-6, M-9, M-10, M-11, M-12 c)

- tb-list applies the detached limit after the edit filter (reads up to 500
  IDs) and notes when more are not listed (M-4); an invalid template_id is
  invalid input, shared with ThemeBuilderAccess::template() (M-5).
- Templates carry their status; tb-list notes that a live template outside
  publish (e.g. trashed) can only be switched off (M-10).
- changeableTemplate() refuses without canUse() first, like tb-list and
  tb-create-template; the layout/template refusal difference is documented
  (M-9).
- tb-create-template reads the default template from editableLiveTemplates()
  (M-11); the condition-ID character set (role slugs) is documented (M-6);
  tb-assign documents that a failed reattach keeps Divi's unused marks
  removed (M-12 c).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-27 23:12:14 +02:00
co-authored by Claude Opus 5.5
parent e7bbe1e9d7
commit 14bc954ecc
10 changed files with 176 additions and 18 deletions
+6 -3
View File
@@ -23,12 +23,13 @@ Kopf- und Fußzeile heißen „global“, weil andere Vorlagen sie mitbenutzen.
## Werkzeuge (über `mcp-adapter-execute-ability`) ## Werkzeuge (über `mcp-adapter-execute-ability`)
- `lit/tb-list`: alle Vorlagen mit `enabled`, `live` (für Besucher wirksam), Bedingungen (`use_on`, - `lit/tb-list`: alle Vorlagen mit `enabled`, `live` (für Besucher wirksam), `status`, Bedingungen (`use_on`,
`exclude_from` und Beschriftungen), Layouts (`id`, Titel, `shared_with` = andere Vorlagen mit demselben `exclude_from` und Beschriftungen), Layouts (`id`, Titel, `shared_with` = andere Vorlagen mit demselben
Layout) und `template_rev`. `template_id`: nur diese Vorlage. `conditions: true` liefert alle Bedingungen, Layout) und `template_rev`. `template_id`: nur diese Vorlage. `conditions: true` liefert alle Bedingungen,
die Divi anbietet; eine ID, die auf „:“ endet, braucht ein Objekt am Ende, z. B. die Divi anbietet; eine ID, die auf „:“ endet, braucht ein Objekt am Ende, z. B.
`singular:post_type:page:id:42`. `detached` zeigt Vorlagen, die ein Speichern im Theme Builder entfernt hat `singular:post_type:page:id:42`. `detached` zeigt Vorlagen, die ein Speichern im Theme Builder entfernt hat
(siehe unten); `draft_pending` zeigt ungespeicherte Änderungen im Theme Builder. (siehe unten, höchstens 50 je Aufruf); `draft_pending` zeigt ungespeicherte Änderungen im Theme Builder. Eine
aktive Vorlage, die nicht veröffentlicht ist (z. B. im Papierkorb), lässt sich nur deaktivieren.
- `lit/tb-create-template`: `title`, je Bereich (`header`, `body`, `footer`) `source`: - `lit/tb-create-template`: `title`, je Bereich (`header`, `body`, `footer`) `source`:
`"new"` (neues Layout; optional `nodes` wie bei `lit-divi-insert` und `title`), `"global"` (Kopf-/Fußzeile `"new"` (neues Layout; optional `nodes` wie bei `lit-divi-insert` und `title`), `"global"` (Kopf-/Fußzeile
der Standardvorlage mitbenutzen; nicht für `body`), `"default"` (nichts Eigenes, Standard bei fehlender der Standardvorlage mitbenutzen; nicht für `body`), `"default"` (nichts Eigenes, Standard bei fehlender
@@ -50,7 +51,9 @@ Kopf- und Fußzeile heißen „global“, weil andere Vorlagen sie mitbenutzen.
Erlaubt sind nur die Bedingungen, die Divi selbst anbietet (`lit/tb-list` mit `conditions: true`), höchstens 50 Erlaubt sind nur die Bedingungen, die Divi selbst anbietet (`lit/tb-list` mit `conditions: true`), höchstens 50
je Liste. Ein Objekt am Ende (Seite, Beitrag, Begriff, Person) muss existieren und für das Konto lesbar bzw. je Liste. Ein Objekt am Ende (Seite, Beitrag, Begriff, Person) muss existieren und für das Konto lesbar bzw.
zuweisbar sein; sonst lautet die Antwort wie bei einer unbekannten ID. Bedingungen für Autoren zuweisbar sein; sonst lautet die Antwort wie bei einer unbekannten ID. Bedingungen für Autoren
(`archive:user:id:…`) verlangen das Recht, Benutzer aufzulisten (`list_users`) – strenger als Divi. (`archive:user:id:…`) verlangen das Recht, Benutzer aufzulisten (`list_users`) – strenger als Divi. Bedingungs-IDs
bestehen nur aus Buchstaben, Ziffern, `_`, `:` und `-`; eine Rolle, deren Kennung andere Zeichen enthält (manche
Rollen-Plugins), dann im Divi Theme Builder setzen.
## Felder im Inhalt-Layout (Dynamic Content) ## Felder im Inhalt-Layout (Dynamic Content)
@@ -26,7 +26,9 @@ defined('ABSPATH') || exit;
* live list (only on the user's request, B19; only one no Theme Builder post lists – tb-list's detached, audit L4) * live list (only on the user's request, B19; only one no Theme Builder post lists – tb-list's detached, audit L4)
* – always disabled, Divi's "unused" marks removed from it and its * – always disabled, Divi's "unused" marks removed from it and its
* layouts, which must still be published (B10). Templates have no revisions: the previous conditions are in the * layouts, which must still be published (B10). Templates have no revisions: the previous conditions are in the
* answer and the change log; a failed write restores them (and undoes the reattach). * answer and the change log; a failed write restores them (and undoes the reattach). The undo does not put back
* Divi's "unused" marks a reattach removed (review M-12 c): the template is detached again, but Divi no longer
* trashes it after seven days – harmless, it stays under detached in lit/tb-list.
*/ */
final class TbAssignAbility implements Ability final class TbAssignAbility implements Ability
{ {
@@ -216,11 +216,14 @@ final class TbCreateTemplateAbility implements Ability
return $plan; return $plan;
} }
/** The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11). */ /**
* The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11) – from the one shared
* split of the live templates (ThemeBuilderAccess::editableLiveTemplates(), ruling Q21, review M-11).
*/
private function defaultTemplate(): ?Template private function defaultTemplate(): ?Template
{ {
foreach ($this->access->store()->liveTemplates() as $template) { foreach ($this->access->editableLiveTemplates()['templates'] as $template) {
if ($template->default && $this->access->canEditTemplate($template->id)) { if ($template->default) {
return $template; return $template;
} }
} }
@@ -26,6 +26,9 @@ final class TbListAbility implements Ability
{ {
public const DETACHED_LIMIT = 50; public const DETACHED_LIMIT = 50;
/** At most this many detached IDs are read to find DETACHED_LIMIT the user may edit (review M-4). */
public const DETACHED_SCAN = 500;
public function __construct(private ThemeBuilderAccess $access, private TemplateConditions $conditions) public function __construct(private ThemeBuilderAccess $access, private TemplateConditions $conditions)
{ {
} }
@@ -110,18 +113,29 @@ final class TbListAbility implements Ability
$split = $this->access->editableLiveTemplates(); $split = $this->access->editableLiveTemplates();
$live = $split['templates']; $live = $split['templates'];
$hiddenLive = count(array_filter($split['hidden'], static fn (Template $t): bool => $t->isLive())); $hiddenLive = count(array_filter($split['hidden'], static fn (Template $t): bool => $t->isLive()));
// Review M-4: the limit counts only templates the user may edit; foreign ones do not crowd them out.
$detached = []; $detached = [];
foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) { $detachedIds = $store->detachedIds(self::DETACHED_SCAN);
$moreDetached = count($detachedIds) >= self::DETACHED_SCAN;
foreach ($detachedIds as $id) {
$template = $store->template($id, false); $template = $store->template($id, false);
if ($template !== null && $this->access->canEditTemplate($template->id)) { if ($template === null || ! $this->access->canEditTemplate($template->id)) {
$detached[] = $template; continue;
} }
if (count($detached) >= self::DETACHED_LIMIT) {
$moreDetached = true;
break;
}
$detached[] = $template;
} }
// Only what the user may see counts for shared_with (B11). // Only what the user may see counts for shared_with (B11).
$shared = array_merge($live, $detached); $shared = array_merge($live, $detached);
if (isset($input['template_id'])) { if (isset($input['template_id'])) {
$id = (int) PostGuard::id($input['template_id']); $id = PostGuard::id($input['template_id']);
if ($id === null) {
return ThemeBuilderAccess::invalidTemplateId();
}
$only = static fn (array $list): array => array_values( $only = static fn (array $list): array => array_values(
array_filter($list, static fn (Template $template): bool => $template->id === $id) array_filter($list, static fn (Template $template): bool => $template->id === $id)
); );
@@ -149,12 +163,31 @@ final class TbListAbility implements Ability
$hiddenLive $hiddenLive
); );
} }
$trashed = array_values(array_filter(
$live,
static fn (Template $t): bool => $t->isLive() && $t->status !== 'publish'
));
if ($trashed !== []) {
// Review M-10: tb-assign and switching on read such a template as missing (ThemeBuilderAccess::template()).
$notes[] = sprintf(
'Aktiv, aber nicht veröffentlicht (z. B. im Papierkorb, siehe status): %s – nur deaktivieren '
. '(lit/tb-activate enabled: false); Bedingungen ändern oder erneut aktivieren geht erst, wenn '
. 'die Vorlage in WordPress wiederhergestellt ist.',
implode(', ', array_map(static fn (Template $t): string => (string) $t->id, $trashed))
);
}
if ($detached !== []) { if ($detached !== []) {
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand ' $notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand '
. 'hat sie dort bewusst gelöscht; Divi löscht als ungenutzt markierte Vorlagen und Layouts 7 Tage ' . 'hat sie dort bewusst gelöscht; Divi löscht als ungenutzt markierte Vorlagen und Layouts 7 Tage '
. 'nach der Markierung. Nur auf ausdrücklichen Wunsch wieder zuordnen (bleibt deaktiviert): ' . 'nach der Markierung. Nur auf ausdrücklichen Wunsch wieder zuordnen (bleibt deaktiviert): '
. 'lit/tb-assign mit reattach: true.'; . 'lit/tb-assign mit reattach: true.';
} }
if ($moreDetached && ! isset($input['template_id'])) {
$notes[] = sprintf(
'Weitere entfernte Vorlagen nicht aufgeführt (höchstens %d je Aufruf).',
self::DETACHED_LIMIT
);
}
$result = [ $result = [
'theme_builder_id' => $store->liveId(), 'theme_builder_id' => $store->liveId(),
@@ -119,6 +119,7 @@ final class Template
return [ return [
'id' => $this->id, 'id' => $this->id,
'title' => $this->title, 'title' => $this->title,
'status' => $this->status,
'default' => $this->default, 'default' => $this->default,
'enabled' => $this->enabled, 'enabled' => $this->enabled,
'live' => $this->isLive(), 'live' => $this->isLive(),
@@ -18,6 +18,9 @@ defined('ABSPATH') || exit;
* of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with * of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with
* assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for * assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for
* missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here. * missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here.
* Every condition ID is limited to letters, digits, "_", ":" and "-" (at most 200 characters): a role slug with
* other characters (possible with role plugins) is refused as an invalid condition ID and must be set in Divi's
* Theme Builder (review M-6).
*/ */
final class TemplateConditions final class TemplateConditions
{ {
@@ -19,7 +19,12 @@ defined('ABSPATH') || exit;
* Builder page, theme-builder.php:2059-2063, ruling Q15) and the Divi role option theme_builder (off for * Builder page, theme-builder.php:2059-2063, ruling Q15) and the Divi role option theme_builder (off for
* every role but administrator by default, F12); one layout or template additionally edit_post on it. * every role but administrator by default, F12); one layout or template additionally edit_post on it.
* Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID * Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID
* (P12 pattern). The group's "nur lesen" stops writes (E9). Not final so abilities can be tested with a mock. * (P12 pattern); the Theme Builder tools themselves (tb-list, tb-create-template, tb-assign, tb-activate) refuse
* without canUse() as forbidden, whatever the ID (review M-9). With the Theme Builder rights, a template the user
* may not edit still reads like a missing one (template(), ruling Q18 – templates are listed by tb-list), while a
* layout without edit_post answers forbidden (layout(): only accounts with the Theme Builder rights get there, and
* tb-list names the layout IDs of their templates). The group's "nur lesen" stops writes (E9). Not final so
* abilities can be tested with a mock.
*/ */
class ThemeBuilderAccess class ThemeBuilderAccess
{ {
@@ -146,10 +151,7 @@ class ThemeBuilderAccess
{ {
$id = PostGuard::id($templateId); $id = PostGuard::id($templateId);
if ($id === null) { if ($id === null) {
return Errors::invalidInput( return self::invalidTemplateId();
'template_id fehlt oder ist keine ID.',
['template_id' => 'ID aus lit/tb-list']
);
} }
$post = get_post($id); $post = get_post($id);
@@ -168,6 +170,12 @@ class ThemeBuilderAccess
return $forWrite ? ($this->writableGate() ?? $post) : $post; return $forWrite ? ($this->writableGate() ?? $post) : $post;
} }
/** The answer for a template_id that is missing or no ID (template(), lit/tb-list – review M-5). */
public static function invalidTemplateId(): WP_Error
{
return Errors::invalidInput('template_id fehlt oder ist keine ID.', ['template_id' => 'ID aus lit/tb-list']);
}
/** /**
* A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate * A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate
* (template(), a template the user may not edit reads as missing – ruling Q18), no Theme Builder draft (E7), * (template(), a template the user may not edit reads as missing – ruling Q18), no Theme Builder draft (E7),
@@ -180,6 +188,12 @@ class ThemeBuilderAccess
*/ */
public function changeableTemplate(array $input, bool $offSwitch = false): Template|WP_Error public function changeableTemplate(array $input, bool $offSwitch = false): Template|WP_Error
{ {
// Review M-9: without the Theme Builder rights refuse like tb-list/tb-create-template – independent of the ID.
$refusal = $this->canUse();
if ($refusal !== null) {
return $refusal;
}
$post = $this->template($input['template_id'] ?? null, true); $post = $this->template($input['template_id'] ?? null, true);
if ($post instanceof WP_Error) { if ($post instanceof WP_Error) {
$id = $offSwitch && Errors::codeOf($post) === 'not_found' $id = $offSwitch && Errors::codeOf($post) === 'not_found'
+5 -1
View File
@@ -127,7 +127,11 @@ final class ThemeBuilderFixtures
$store->allows('liveTemplates')->andReturn($templates); $store->allows('liveTemplates')->andReturn($templates);
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates)); $store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null)->byDefault(); $store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null)->byDefault();
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached)); $store->allows('detachedIds')->andReturnUsing(static fn (int $limit): array => array_slice(
array_map(static fn (Template $t): int => $t->id, $detached),
0,
$limit
))->byDefault();
$store->allows('isListedAnywhere')->andReturnUsing( $store->allows('isListedAnywhere')->andReturnUsing(
static fn (int $id): bool => in_array($id, array_map(static fn (Template $t): int => $t->id, $templates), true) static fn (int $id): bool => in_array($id, array_map(static fn (Template $t): int => $t->id, $templates), true)
)->byDefault(); )->byDefault();
@@ -94,6 +94,70 @@ final class TbListAbilityTest extends TestCase
self::assertToolError(self::ability($store)->execute(['template_id' => 4711]), 'not_found'); self::assertToolError(self::ability($store)->execute(['template_id' => 4711]), 'not_found');
} }
/** Review M-5: an invalid template_id is named as invalid, not as template 0. */
public function testAnInvalidTemplateIdIsInvalidInput(): void
{
$result = self::ability(ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065)]))
->execute(['template_id' => 'abc']);
self::assertToolError($result, 'validation_failed');
self::assertSame('template_id fehlt oder ist keine ID.', self::clientError($result)['message']);
}
/**
* Review M-4: the detached limit applies to the templates the user may edit – foreign detached ones do not
* crowd them out – and a note says when more are not listed.
*/
public function testTheDetachedLimitCountsOnlyEditableTemplates(): void
{
$foreign = range(3000, 3000 + TbListAbility::DETACHED_LIMIT - 1);
$own = range(4000, 4000 + TbListAbility::DETACHED_LIMIT);
$detached = array_map(
static fn (int $id): \LIT\MCP\Integrations\Divi\ThemeBuilder\Template => ThemeBuilderFixtures::template($id, ['attached' => false]),
array_merge($foreign, $own)
);
$store = ThemeBuilderFixtures::store([], 0, $detached);
$asked = [];
$store->allows('detachedIds')->andReturnUsing(static function (int $limit) use (&$asked, $detached): array {
$asked[] = $limit;
return array_slice(array_map(static fn ($t): int => $t->id, $detached), 0, $limit);
});
self::denyEditing($foreign);
$result = self::ability($store)->execute([]);
self::assertSame(array_slice($own, 0, TbListAbility::DETACHED_LIMIT), array_column($result['detached'], 'id'));
self::assertStringContainsString('Weitere entfernte Vorlagen nicht aufgeführt', implode(' ', $result['notes']));
self::assertSame([TbListAbility::DETACHED_SCAN], $asked);
$few = ThemeBuilderFixtures::store([], 0, [ThemeBuilderFixtures::template(1066, ['attached' => false])]);
self::assertStringNotContainsString(
'Weitere entfernte Vorlagen',
implode(' ', self::ability($few)->execute([])['notes'])
);
}
/**
* Review M-10: a live template in the trash is listed with its status and the note that it can only be switched
* off (tb-assign and switching on read it as missing).
*/
public function testATrashedLiveTemplateSaysItCanOnlyBeSwitchedOff(): void
{
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash'])]);
$result = self::ability($store)->execute([]);
self::assertSame('trash', $result['templates'][0]['status']);
self::assertStringContainsString('lit/tb-activate enabled: false', implode(' ', $result['notes']));
self::assertStringContainsString('1066', implode(' ', $result['notes']));
$published = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
$plain = self::ability($published)->execute([]);
self::assertSame('publish', $plain['templates'][0]['status']);
self::assertStringNotContainsString('Papierkorb', implode(' ', $plain['notes']));
}
public function testNeedsTheThemeBuilderRole(): void public function testNeedsTheThemeBuilderRole(): void
{ {
$ability = self::ability(ThemeBuilderFixtures::store(), ['theme_builder']); $ability = self::ability(ThemeBuilderFixtures::store(), ['theme_builder']);
@@ -208,6 +208,31 @@ final class ThemeBuilderAccessTest extends TestCase
); );
} }
/**
* Review M-9: without the Theme Builder rights tb-assign and tb-activate refuse like tb-list and
* tb-create-template (forbidden, independent of the ID) instead of "Vorlage X gibt es nicht".
*/
public function testChangeableTemplateRefusesWithoutTheThemeBuilderRightsFirst(): void
{
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)]);
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)]);
$rev = ThemeBuilderFixtures::template(1066)->rev();
foreach ([1066, 4711, 'abc'] as $id) {
$refused = ThemeBuilderFixtures::access(['theme_builder'], true, $store)
->changeableTemplate(['template_id' => $id, 'template_rev' => $rev]);
self::assertToolError($refused, 'forbidden');
self::assertStringNotContainsString((string) $id, self::clientError($refused)['message']);
}
foreach ([true, false] as $offSwitch) {
self::assertToolError(
ThemeBuilderFixtures::access(['theme_builder'], true, $store)
->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev], $offSwitch),
'forbidden'
);
}
}
/** Ruling Q18: a template the user may not edit answers like a missing one, also for writing. */ /** Ruling Q18: a template the user may not edit answers like a missing one, also for writing. */
public function testChangeableTemplateHidesTemplatesTheUserMayNotEdit(): void public function testChangeableTemplateHidesTemplatesTheUserMayNotEdit(): void
{ {
@@ -242,13 +267,19 @@ final class ThemeBuilderAccessTest extends TestCase
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([
ThemeBuilderFixtures::template(1066, ['status' => 'trash']), ThemeBuilderFixtures::template(1066, ['status' => 'trash']),
])), ])),
ThemeBuilderFixtures::access(['theme_builder'], true, ThemeBuilderFixtures::store([$trashed])), ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$trashed]), false),
]; ];
foreach ($notLive as $index => $other) { foreach ($notLive as $index => $other) {
$refused = $other->changeableTemplate($input, true); $refused = $other->changeableTemplate($input, true);
self::assertToolError($refused, 'not_found', (string) $index); self::assertToolError($refused, 'not_found', (string) $index);
self::assertSame(self::clientError($missing), self::clientError($refused), (string) $index); self::assertSame(self::clientError($missing), self::clientError($refused), (string) $index);
} }
// Review M-9: without the Theme Builder rights the refusal does not depend on the ID.
self::assertToolError(
ThemeBuilderFixtures::access(['theme_builder'], true, ThemeBuilderFixtures::store([$trashed]))
->changeableTemplate($input, true),
'forbidden'
);
self::assertToolError( self::assertToolError(
ThemeBuilderFixtures::access([], false, ThemeBuilderFixtures::store([$trashed]))->changeableTemplate($input, true), ThemeBuilderFixtures::access([], false, ThemeBuilderFixtures::store([$trashed]))->changeableTemplate($input, true),