feat: Divi tools edit Theme Builder layouts; visible layouts need confirm
TB layouts load through ThemeBuilderAccess (P12), live layouts (attached + enabled, Q8) need a dry run and confirm (Q9) and get the shared restore point of VisibleEdits, extracted from GlobalEdits (Q2: backup when revisions are off, P9 refusals, P14 naming). divi/post-content only in body layouts (F22), also for nested stored nodes. SaveScope::ThemeBuilderLayout sets Divi flags and clears site CSS + TB cache. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
@@ -18,7 +19,8 @@ defined('ABSPATH') || exit;
|
||||
* rules limit the depth); parent module → its child modules (catalog). Columns only come with
|
||||
* their row; specialty sections, specialty columns (module.advanced.specialtyColumns or
|
||||
* row-inner children, ColumnModule.php:306-324) and read-only nodes (opaque, non-Divi) are not
|
||||
* rebuilt. Pure PHP.
|
||||
* rebuilt. F22: in Theme Builder layouts only modules Divi renders there (appearance.themeBuilderLayouts,
|
||||
* e.g. divi/post-content only in the body). Pure PHP.
|
||||
*/
|
||||
final class StructureRules
|
||||
{
|
||||
@@ -26,7 +28,8 @@ final class StructureRules
|
||||
private const ROWS = ['divi/row', 'divi/row-inner'];
|
||||
private const FULLWIDTH = 'fullwidth-module';
|
||||
|
||||
public function __construct(private ModuleCatalog $catalog)
|
||||
/** @param ?string $themeBuilderArea "header", "body" or "footer" when the entry is a Theme Builder layout. */
|
||||
public function __construct(private ModuleCatalog $catalog, private ?string $themeBuilderArea = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -37,6 +40,11 @@ final class StructureRules
|
||||
return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child);
|
||||
}
|
||||
|
||||
$area = $this->areaViolation($child);
|
||||
if ($area !== null) {
|
||||
return $area;
|
||||
}
|
||||
|
||||
if ($parent === null) {
|
||||
return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt';
|
||||
}
|
||||
@@ -116,4 +124,41 @@ final class StructureRules
|
||||
|
||||
return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)';
|
||||
}
|
||||
|
||||
/**
|
||||
* F22 for stored nodes that arrive with their children (copy, library entry, duplicate): the first module
|
||||
* below $block that Divi does not render in this Theme Builder area; null for pages.
|
||||
*/
|
||||
public function nestedAreaViolation(Block $block): ?string
|
||||
{
|
||||
if ($this->themeBuilderArea === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($block->children() as $child) {
|
||||
$violation = $this->areaViolation(GlobalInstance::effectiveName($child))
|
||||
?? $this->nestedAreaViolation($child);
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** F22: in a Theme Builder layout, a module Divi renders only in other areas (Divi's own render rule). */
|
||||
private function areaViolation(string $child): ?string
|
||||
{
|
||||
$areas = $this->catalog->themeBuilderAreas($child);
|
||||
if ($areas === null || $this->themeBuilderArea === null || in_array($this->themeBuilderArea, $areas, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s zeigt Divi im Bereich „%s“ nicht (nur %s)',
|
||||
$child,
|
||||
$this->themeBuilderArea,
|
||||
implode(', ', $areas)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ final class TreeEditor
|
||||
public function insert(array $position, array $blocks): ?string
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
|
||||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block))
|
||||
?? $this->rules->nestedAreaViolation($block);
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -50,6 +53,7 @@ final class DiviIntegration implements Integration
|
||||
private DiviModules $modules;
|
||||
private DesignSystemReader $design;
|
||||
private LibraryAccess $access;
|
||||
private ThemeBuilderAccess $themeBuilderAccess;
|
||||
|
||||
/**
|
||||
* @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
|
||||
@@ -60,6 +64,10 @@ final class DiviIntegration implements Integration
|
||||
* and lit-library-insert follow it (ruling P9, E9, audit M2); null = always.
|
||||
* @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled –
|
||||
* lit/divi-node-copy follows it for library entries as source (audit L5); null = always.
|
||||
* @param (Closure(): bool)|null $themeBuilderWritable Tools-tab gate for writes to Theme Builder layouts in
|
||||
* the Divi tools (group "Divi Theme Builder" writable, E9); null = always.
|
||||
* @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi
|
||||
* tools at all (group "Divi Theme Builder" not disabled); null = always.
|
||||
*/
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
@@ -67,11 +75,20 @@ final class DiviIntegration implements Integration
|
||||
?Closure $libraryWritable = null,
|
||||
?Closure $libraryVisible = null,
|
||||
private ?Closure $diviWritable = null,
|
||||
private ?Closure $libraryInsertable = null
|
||||
private ?Closure $libraryInsertable = null,
|
||||
?Closure $themeBuilderWritable = null,
|
||||
?Closure $themeBuilderVisible = null
|
||||
) {
|
||||
$this->modules = new DiviModules();
|
||||
$this->design = new DesignSystemReader();
|
||||
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible);
|
||||
$roles = new DiviRoles();
|
||||
$this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible);
|
||||
$this->themeBuilderAccess = new ThemeBuilderAccess(
|
||||
new DiviThemeBuilder(),
|
||||
$roles,
|
||||
$themeBuilderWritable,
|
||||
$themeBuilderVisible
|
||||
);
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
@@ -161,12 +178,13 @@ final class DiviIntegration implements Integration
|
||||
$this->log,
|
||||
$this->design,
|
||||
new DiviComposition($this->modules, $this->design),
|
||||
new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles()))
|
||||
new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())),
|
||||
new ThemeBuilderEdits($this->themeBuilderAccess)
|
||||
);
|
||||
}
|
||||
|
||||
private function loader(): DiviPageLoader
|
||||
{
|
||||
return new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||||
return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
@@ -27,25 +27,22 @@ defined('ABSPATH') || exit;
|
||||
* confirm: true nothing is saved and the answer lists those pages (a dry run of the fully
|
||||
* validated change); with it the
|
||||
* save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
|
||||
* copy is made first – the revision itself is DiviSaver's restore point, as for every page.
|
||||
* copy is made first – the revision itself is DiviSaver's restore point, as for every page
|
||||
* (VisibleEdits, shared with live Theme Builder layouts, ruling Q2).
|
||||
* Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may
|
||||
* not edit_post one of the published, private or scheduled posts that show the entry (rulings
|
||||
* P8/P8a, stricter than Divi); the dry run names them already (blocked_ids – posts the user may
|
||||
* not read only as counts, audit L4). One GlobalUsages report per call (audit L1); above
|
||||
* GlobalUsages::HARD_CAP live posts a confirmed edit is refused (Divi builder instead).
|
||||
* The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for
|
||||
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37).
|
||||
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37; VisibleEdits).
|
||||
*/
|
||||
final class GlobalEdits
|
||||
{
|
||||
/** Refusals name at most this many page IDs. */
|
||||
private const LISTED_IDS = 10;
|
||||
|
||||
/** @var Closure(WP_Post): int */
|
||||
private Closure $backup;
|
||||
|
||||
/** @var Closure(): bool */
|
||||
private Closure $trashEnabled;
|
||||
private VisibleEdits $visible;
|
||||
|
||||
/**
|
||||
* @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup().
|
||||
@@ -58,9 +55,7 @@ final class GlobalEdits
|
||||
private Parser $parser = new Parser(),
|
||||
?Closure $trashEnabled = null
|
||||
) {
|
||||
$this->backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item);
|
||||
$this->trashEnabled = $trashEnabled
|
||||
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
|
||||
$this->visible = new VisibleEdits($backup, $trashEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,17 +103,21 @@ final class GlobalEdits
|
||||
);
|
||||
}
|
||||
|
||||
$refusal = $this->pagesRefusal(self::label($id, $global), $report) ?? $this->backupRefusal($item);
|
||||
$refusal = $this->pagesRefusal(self::label($id, $global), $report)
|
||||
?? $this->visible->backupRefusal(
|
||||
$item,
|
||||
sprintf('Bibliothekseintrag %d', $id),
|
||||
'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder '
|
||||
. 'bearbeiten.'
|
||||
);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$details = $report->listing();
|
||||
if (! wp_revisions_enabled($item)) {
|
||||
$details['backup_id'] = ($this->backup)($item);
|
||||
}
|
||||
|
||||
return ['scope' => SaveScope::GlobalItem, 'details' => $details];
|
||||
return [
|
||||
'scope' => SaveScope::GlobalItem,
|
||||
'details' => array_merge($report->listing(), $this->visible->backup($item)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,20 +136,15 @@ final class GlobalEdits
|
||||
return $error;
|
||||
}
|
||||
|
||||
$data = $error->get_error_data();
|
||||
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
|
||||
|
||||
return Errors::confirmRequired(
|
||||
Errors::clientMessage($error)
|
||||
. sprintf(
|
||||
' %s: wirkt auf %d %s (siehe affected).',
|
||||
return VisibleEdits::merged(
|
||||
$error,
|
||||
sprintf(
|
||||
'%s: wirkt auf %d %s (siehe affected).',
|
||||
$global ? 'Globaler Bibliothekseintrag' : 'Bibliothekseintrag (als globales Modul verwendet)',
|
||||
$report->count,
|
||||
self::pages($report->count)
|
||||
)
|
||||
. self::hiddenNote($report)
|
||||
. self::blockedNote($report),
|
||||
array_merge($details, $report->details())
|
||||
) . self::hiddenNote($report) . self::blockedNote($report),
|
||||
$report->details()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,39 +248,4 @@ final class GlobalEdits
|
||||
{
|
||||
return $count === 1 ? 'Seite' : 'Seiten';
|
||||
}
|
||||
|
||||
/** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */
|
||||
private function backupRefusal(WP_Post $item): ?WP_Error
|
||||
{
|
||||
if (wp_revisions_enabled($item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hint = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder '
|
||||
. 'bearbeiten.';
|
||||
if (! current_user_can('unfiltered_html')) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und die Sicherungskopie wäre für dieses '
|
||||
. 'Konto nicht verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.',
|
||||
(int) $item->ID
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
if (! ($this->trashEnabled)()) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und der Papierkorb ist deaktiviert '
|
||||
. '(EMPTY_TRASH_DAYS = 0) – die Sicherungskopie würde sofort gelöscht; nichts wurde '
|
||||
. 'gespeichert.',
|
||||
(int) $item->ID
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ defined('ABSPATH') || exit;
|
||||
class DiviComposition
|
||||
{
|
||||
private ?int $postId = null;
|
||||
private ?string $themeBuilderArea = null;
|
||||
private ?VariableRef $references = null;
|
||||
private ?Placeholders $placeholders = null;
|
||||
private ?ValueCleaner $cleaner = null;
|
||||
@@ -52,11 +53,12 @@ class DiviComposition
|
||||
) {
|
||||
}
|
||||
|
||||
/** A fresh composition for entry $postId (own VariableRef with that entry's live map). */
|
||||
public function forPost(int $postId): static
|
||||
/** A fresh composition for entry $postId (own VariableRef with its live map; TB area for StructureRules). */
|
||||
public function forPost(int $postId, ?string $themeBuilderArea = null): static
|
||||
{
|
||||
$bound = clone $this;
|
||||
$bound->postId = $postId;
|
||||
$bound->themeBuilderArea = $themeBuilderArea;
|
||||
$bound->references = null;
|
||||
$bound->placeholders = null;
|
||||
$bound->cleaner = null;
|
||||
@@ -116,7 +118,7 @@ class DiviComposition
|
||||
/** @internal Public for tests; code uses composer() / editor(). */
|
||||
public function rules(): StructureRules
|
||||
{
|
||||
return new StructureRules($this->modules->catalog());
|
||||
return new StructureRules($this->modules->catalog(), $this->themeBuilderArea);
|
||||
}
|
||||
|
||||
/** E6: the check for stored nodes that move into this composition's entry (same cleaner and references). */
|
||||
|
||||
@@ -22,10 +22,23 @@ final class DiviPage
|
||||
public readonly WP_Post $post,
|
||||
public readonly Document $document,
|
||||
public readonly NodeIndex $index,
|
||||
private bool $libraryItem = false
|
||||
private bool $libraryItem = false,
|
||||
private ?string $themeBuilderArea = null
|
||||
) {
|
||||
}
|
||||
|
||||
/** Whether the entry is a Theme Builder layout (header, body or footer), loaded through ThemeBuilderAccess. */
|
||||
public function isThemeBuilderLayout(): bool
|
||||
{
|
||||
return $this->themeBuilderArea !== null;
|
||||
}
|
||||
|
||||
/** "header", "body" or "footer" for a Theme Builder layout, else null. */
|
||||
public function themeBuilderArea(): ?string
|
||||
{
|
||||
return $this->themeBuilderArea;
|
||||
}
|
||||
|
||||
/** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */
|
||||
public function isLibraryItem(): bool
|
||||
{
|
||||
@@ -47,6 +60,11 @@ final class DiviPage
|
||||
|
||||
public function editUrl(): string
|
||||
{
|
||||
if ($this->themeBuilderArea !== null) {
|
||||
// Divi opens TB layouts from the Theme Builder screen (the builder URL needs its nonce).
|
||||
return (string) admin_url('admin.php?page=et_theme_builder');
|
||||
}
|
||||
|
||||
// A library entry has no public permalink (public: false); Divi's library list opens it here.
|
||||
return $this->libraryItem
|
||||
? (string) get_edit_post_link($this->post, 'raw')
|
||||
|
||||
@@ -9,6 +9,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Log;
|
||||
@@ -18,7 +20,8 @@ use WP_Post;
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other
|
||||
* Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), Theme Builder
|
||||
* layouts via ThemeBuilderAccess (Etappe 3b; hidden ones answer like a missing ID, P12), every other
|
||||
* entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4
|
||||
* shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content
|
||||
* is kept, never edited.
|
||||
@@ -31,7 +34,8 @@ final class DiviPageLoader
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private Parser $parser = new Parser(),
|
||||
private ?LibraryAccess $library = null
|
||||
private ?LibraryAccess $library = null,
|
||||
private ?ThemeBuilderAccess $themeBuilder = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -50,9 +54,10 @@ final class DiviPageLoader
|
||||
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
|
||||
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
|
||||
{
|
||||
// Library entries (E1): with a library access, for reading and writing.
|
||||
// Library entries (E1) and Theme Builder layouts (Etappe 3b) through their access, everything else PostGuard.
|
||||
$item = $this->library?->item($postId, $forWrite);
|
||||
$post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
|
||||
$layout = $item === null ? $this->themeBuilder?->layout($postId, $forWrite) : null;
|
||||
$post = $item ?? $layout ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
|
||||
if ($post instanceof WP_Error) {
|
||||
return $post;
|
||||
}
|
||||
@@ -84,7 +89,9 @@ final class DiviPageLoader
|
||||
);
|
||||
}
|
||||
|
||||
return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post);
|
||||
$area = $layout instanceof WP_Post ? DiviThemeBuilder::areaOf((string) $layout->post_type) : null;
|
||||
|
||||
return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post, $area);
|
||||
}
|
||||
|
||||
/** The entry loaded for writing and checked by writable() – the refusal of either step. */
|
||||
@@ -99,14 +106,16 @@ final class DiviPageLoader
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional checks for writing: the type must use the Divi builder (library entries always do),
|
||||
* Additional checks for writing: the type must use the Divi builder (library entries and Theme Builder
|
||||
* layouts always do),
|
||||
* the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule
|
||||
* (spec §2).
|
||||
*/
|
||||
public function writable(DiviPage $page): ?WP_Error
|
||||
{
|
||||
$post = $page->post;
|
||||
if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
|
||||
$builderPost = $page->isLibraryItem() || $page->isThemeBuilderLayout();
|
||||
if (! $builderPost && ! $this->schema->diviEnabled((string) $post->post_type)) {
|
||||
return Errors::unsupported(
|
||||
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type),
|
||||
'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).'
|
||||
|
||||
@@ -387,7 +387,8 @@ final class DiviReplaceTextAbility implements Ability
|
||||
}
|
||||
|
||||
/**
|
||||
* A page commit() refused: its message, and for a global library entry the pages it shows on.
|
||||
* A page commit() refused: its message, and for a global library entry the pages it shows on (for a live
|
||||
* Theme Builder layout the templates).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -398,6 +399,16 @@ final class DiviReplaceTextAbility implements Ability
|
||||
return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.'];
|
||||
}
|
||||
|
||||
if (isset($data['templates'])) {
|
||||
// A layout of a live Theme Builder template (Etappe 3b): visible site-wide, like a global entry.
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => Errors::clientMessage($error) . ' Sichtbare Theme-Builder-Layouts nicht mit '
|
||||
. 'lit/divi-replace-text, sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.',
|
||||
'templates' => $data['templates'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, '
|
||||
|
||||
@@ -52,7 +52,8 @@ defined('ABSPATH') || exit;
|
||||
* S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md.
|
||||
*
|
||||
* SaveScope: library entries get no Divi flags; a global (or referenced) entry additionally clears
|
||||
* the static CSS of every page and the Theme Builder cache.
|
||||
* the static CSS of every page and the Theme Builder cache; a Theme Builder layout gets the Divi flags
|
||||
* and clears like a global entry.
|
||||
*
|
||||
* If saving throws, the previous content is written back (WriteFailed: rolled back or not).
|
||||
* Not final so abilities can be tested with a mock.
|
||||
|
||||
@@ -36,11 +36,13 @@ abstract class DiviWriteAbility implements Ability
|
||||
return ToolResult::schema([
|
||||
'tree_rev' => ['type' => 'string'],
|
||||
// Global library entries (answer F2): the pages that show the entry, and the backup
|
||||
// made when revisions are off.
|
||||
// made when revisions are off (also for live Theme Builder layouts, ruling Q2).
|
||||
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'affected_count' => ['type' => 'integer'],
|
||||
'hidden_count' => ['type' => 'integer'],
|
||||
'backup_id' => ['type' => 'integer'],
|
||||
// Theme Builder layouts: the enabled templates that show the layout.
|
||||
'templates' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
], ['tree_rev']);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||||
use WP_Error;
|
||||
|
||||
@@ -25,7 +26,8 @@ defined('ABSPATH') || exit;
|
||||
* tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver,
|
||||
* then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline,
|
||||
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point
|
||||
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup).
|
||||
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup), Theme
|
||||
* Builder layouts ThemeBuilderEdits (live layouts: dry run/confirm, backup; ruling Q2/Q9).
|
||||
*/
|
||||
final class DiviWriter
|
||||
{
|
||||
@@ -38,7 +40,8 @@ final class DiviWriter
|
||||
private ChangeLog $log,
|
||||
private DesignSystemReader $design,
|
||||
private DiviComposition $composition,
|
||||
private ?GlobalEdits $globals = null
|
||||
private ?GlobalEdits $globals = null,
|
||||
private ?ThemeBuilderEdits $themeBuilder = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -67,11 +70,12 @@ final class DiviWriter
|
||||
|
||||
/**
|
||||
* The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$
|
||||
* references are checked against Divi's dynamic content options for it (ruling S22).
|
||||
* references are checked against Divi's dynamic content options for it (ruling S22); a Theme
|
||||
* Builder layout also by its area (F22: modules Divi renders only elsewhere are refused).
|
||||
*/
|
||||
public function composition(DiviPage $page): DiviComposition
|
||||
{
|
||||
return $this->composition->forPost((int) $page->post->ID);
|
||||
return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +155,7 @@ final class DiviWriter
|
||||
/**
|
||||
* save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one
|
||||
* needs confirm (dry run first), gets a backup when revisions are off and clears every page's
|
||||
* cache; the result then names the affected pages.
|
||||
* cache; the result then names the affected pages. Theme Builder layouts: commitLayout().
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>|WP_Error
|
||||
@@ -159,6 +163,10 @@ final class DiviWriter
|
||||
*/
|
||||
public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
|
||||
{
|
||||
if ($page->isThemeBuilderLayout()) {
|
||||
return $this->commitLayout($ability, $page, $change, $input);
|
||||
}
|
||||
|
||||
if (! $page->isLibraryItem()) {
|
||||
return $this->save($ability, $page, $change, $input);
|
||||
}
|
||||
@@ -179,48 +187,71 @@ final class DiviWriter
|
||||
return $prepared;
|
||||
}
|
||||
|
||||
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Bibliothekseintrags');
|
||||
}
|
||||
|
||||
/**
|
||||
* save() for a Theme Builder layout: a layout visitors see needs confirm and gets the restore point
|
||||
* of a visible change (ThemeBuilderEdits, rulings Q2/Q9); the result names the templates that show it.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>|WP_Error
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function commitLayout(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
|
||||
{
|
||||
if ($this->themeBuilder === null) {
|
||||
return Errors::unsupported(
|
||||
'Theme-Builder-Layouts lassen sich über diese Verbindung nicht ändern.',
|
||||
'Das Layout im Divi Theme Builder bearbeiten.'
|
||||
);
|
||||
}
|
||||
|
||||
if (Serializer::document($page->document) === (string) $page->post->post_content) {
|
||||
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
|
||||
}
|
||||
|
||||
$prepared = $this->themeBuilder->prepare($page, $input);
|
||||
if ($prepared instanceof WP_Error) {
|
||||
return $prepared;
|
||||
}
|
||||
|
||||
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts');
|
||||
}
|
||||
|
||||
/**
|
||||
* save() with the scope GlobalEdits/ThemeBuilderEdits prepared; their details join the result, and a
|
||||
* failure after their backup names it (ruling P14, VisibleEdits). $entry names the entry in that error.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @param array{scope: SaveScope, details: array<string, mixed>} $prepared
|
||||
* @return array<string, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function savePrepared(
|
||||
string $ability,
|
||||
DiviPage $page,
|
||||
DiviChange $change,
|
||||
array $input,
|
||||
array $prepared,
|
||||
string $entry
|
||||
): array {
|
||||
try {
|
||||
$saved = $this->save($ability, $page, $change, $input, $prepared['scope']);
|
||||
} catch (WriteFailed $e) {
|
||||
throw self::keepingBackup($e, $prepared['details']['backup_id'] ?? null);
|
||||
throw VisibleEdits::keepingBackup($e, $prepared['details']['backup_id'] ?? null, $entry);
|
||||
}
|
||||
|
||||
return array_merge($saved, $prepared['details']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P14: GlobalEdits made a backup (revisions off) before the save failed – the error names
|
||||
* it, because it stays in the trash and is the only earlier state.
|
||||
*/
|
||||
private static function keepingBackup(WriteFailed $e, mixed $backupId): WriteFailed
|
||||
{
|
||||
if (! is_int($backupId)) {
|
||||
return $e;
|
||||
}
|
||||
|
||||
$kept = sprintf(
|
||||
'Sicherung %d liegt im Papierkorb (vorheriger Stand des Bibliothekseintrags, in WordPress '
|
||||
. 'wiederherstellbar).',
|
||||
$backupId
|
||||
);
|
||||
if ($e->rolledBack) {
|
||||
return $e->withDetails(
|
||||
['backup_id' => $backupId],
|
||||
$kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.',
|
||||
Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId))
|
||||
);
|
||||
}
|
||||
|
||||
return $e->withDetails(
|
||||
['backup_id' => $backupId],
|
||||
'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und '
|
||||
. 'dem Nutzer Bescheid geben.'
|
||||
);
|
||||
}
|
||||
|
||||
/** A tool's confirm_required on a global library entry, extended by the affected pages. */
|
||||
/** A tool's confirm_required on a global library entry or a live Theme Builder layout, extended by who sees it. */
|
||||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||||
{
|
||||
if ($page->isThemeBuilderLayout()) {
|
||||
return $this->themeBuilder?->notice($page, $error) ?? $error;
|
||||
}
|
||||
|
||||
return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,13 +21,16 @@ enum SaveScope
|
||||
/** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */
|
||||
case GlobalItem;
|
||||
|
||||
/** A Theme Builder layout: Divi flags like the builder (finding 4); shown site-wide – all CSS, TB cache. */
|
||||
case ThemeBuilderLayout;
|
||||
|
||||
public function setsFlags(): bool
|
||||
{
|
||||
return $this === self::Page;
|
||||
return $this === self::Page || $this === self::ThemeBuilderLayout;
|
||||
}
|
||||
|
||||
public function clearsSiteCss(): bool
|
||||
{
|
||||
return $this === self::GlobalItem;
|
||||
return $this === self::GlobalItem || $this === self::ThemeBuilderLayout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||||
use WP_Error;
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The restore point and the confirm merge shared by every Divi write visitors see at once (answer F2
|
||||
* „vorher Revision/Sicherung“, ruling Q2): global or referenced library entries (GlobalEdits) and layouts
|
||||
* of live Theme Builder templates (ThemeBuilderEdits). Their own rules decide whether confirm is needed;
|
||||
* this class holds what is the same for both:
|
||||
* - the revision is DiviSaver's restore point; when revisions are off for the entry, a backup copy
|
||||
* (PostCloner, moved to the trash) is made after confirm and before the save – refused while it could
|
||||
* not be faithful (users without unfiltered_html: its wp_insert_post runs kses) or would not survive
|
||||
* (trash disabled) – ruling P9, S37;
|
||||
* - a failed save after that backup names it (ruling P14);
|
||||
* - a tool's own confirm_required (lit-divi-delete) is extended by the entry's question, so the one
|
||||
* confirm the client sends covers both.
|
||||
*/
|
||||
final class VisibleEdits
|
||||
{
|
||||
/** @var Closure(WP_Post): int */
|
||||
private Closure $backup;
|
||||
|
||||
/** @var Closure(): bool */
|
||||
private Closure $trashEnabled;
|
||||
|
||||
/**
|
||||
* @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup().
|
||||
* @param (Closure(): bool)|null $trashEnabled Whether the trash keeps the backup; null = EMPTY_TRASH_DAYS.
|
||||
*/
|
||||
public function __construct(?Closure $backup = null, ?Closure $trashEnabled = null)
|
||||
{
|
||||
$this->backup = $backup ?? static fn (WP_Post $post): int => (new PostCloner())->backup($post);
|
||||
$this->trashEnabled = $trashEnabled
|
||||
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. $label
|
||||
* names the entry in the message ("Bibliothekseintrag 12"), $hint says what to do instead.
|
||||
*/
|
||||
public function backupRefusal(WP_Post $post, string $label, string $hint): ?WP_Error
|
||||
{
|
||||
if (wp_revisions_enabled($post)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! current_user_can('unfiltered_html')) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für %s abgeschaltet, und die Sicherungskopie wäre für dieses Konto nicht '
|
||||
. 'verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.',
|
||||
$label
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
if (! ($this->trashEnabled)()) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Revisionen sind für %s abgeschaltet, und der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0) '
|
||||
. '– die Sicherungskopie würde sofort gelöscht; nichts wurde gespeichert.',
|
||||
$label
|
||||
),
|
||||
$hint
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backup of $post when revisions are off for it (call backupRefusal() first): ['backup_id' => …],
|
||||
* else nothing – the revision DiviSaver takes is the restore point.
|
||||
*
|
||||
* @return array{backup_id?: int}
|
||||
* @throws WriteFailed When the backup could not be made (nothing was saved).
|
||||
*/
|
||||
public function backup(WP_Post $post): array
|
||||
{
|
||||
return wp_revisions_enabled($post) ? [] : ['backup_id' => ($this->backup)($post)];
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's own confirm_required $error extended by $note (appended to its message) and $details (the
|
||||
* entry's answer fields), keeping the tool's own fields.
|
||||
*
|
||||
* @param array<string, mixed> $details
|
||||
*/
|
||||
public static function merged(WP_Error $error, string $note, array $details): WP_Error
|
||||
{
|
||||
$data = $error->get_error_data();
|
||||
$own = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
|
||||
|
||||
return Errors::confirmRequired(Errors::clientMessage($error) . ' ' . $note, array_merge($own, $details));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P14: a backup was made (revisions off) before the save failed – the error names it, because it
|
||||
* stays in the trash and is the only earlier state. $entry names what it saved ("des Bibliothekseintrags").
|
||||
*/
|
||||
public static function keepingBackup(WriteFailed $e, mixed $backupId, string $entry): WriteFailed
|
||||
{
|
||||
if (! is_int($backupId)) {
|
||||
return $e;
|
||||
}
|
||||
|
||||
$kept = sprintf(
|
||||
'Sicherung %d liegt im Papierkorb (vorheriger Stand %s, in WordPress wiederherstellbar).',
|
||||
$backupId,
|
||||
$entry
|
||||
);
|
||||
if ($e->rolledBack) {
|
||||
return $e->withDetails(
|
||||
['backup_id' => $backupId],
|
||||
$kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.',
|
||||
Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId))
|
||||
);
|
||||
}
|
||||
|
||||
return $e->withDetails(
|
||||
['backup_id' => $backupId],
|
||||
'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und '
|
||||
. 'dem Nutzer Bescheid geben.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,24 @@ final class ModuleCatalog
|
||||
return array_values(array_unique(array_filter($children, 'is_string')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme Builder areas Divi renders the module in (appearance.themeBuilderLayouts, e.g. divi/post-content:
|
||||
* ["body"]; ModuleRegistration.php:509-527); null = no limit.
|
||||
*
|
||||
* @return list<string>|null
|
||||
*/
|
||||
public function themeBuilderAreas(string $name): ?array
|
||||
{
|
||||
$areas = $this->modules[$name]['appearance']['themeBuilderLayouts'] ?? null;
|
||||
if (! is_array($areas)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$areas = array_values(array_filter($areas, 'is_string'));
|
||||
|
||||
return $areas === [] ? null : $areas;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function paths(string $name): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The extra rule of a Divi write to a Theme Builder layout (Etappe 3b, constraint "sichtbare Website nur mit
|
||||
* ausdrücklicher Freigabe"): Divi reads TB layouts live (F22), so a layout that a live template (attached to the
|
||||
* live Theme Builder and enabled, whatever its post status – ruling Q8) shows changes the website at once –
|
||||
* without confirm: true nothing is saved and the answer lists those templates (dry run of the fully validated
|
||||
* change, like global modules, answer F2; ruling Q9). A confirmed edit gets the restore point of every visible
|
||||
* 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.
|
||||
*/
|
||||
final class ThemeBuilderEdits
|
||||
{
|
||||
public function __construct(
|
||||
private ThemeBuilderAccess $access,
|
||||
private VisibleEdits $visible = new VisibleEdits()
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<Template> Live templates that show layout $page in its area. */
|
||||
public function showing(DiviPage $page): array
|
||||
{
|
||||
$area = (string) $page->themeBuilderArea();
|
||||
$id = (int) $page->post->ID;
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->access->store()->liveTemplates(),
|
||||
static fn (Template $template): bool => $template->showsLayout($area, $id)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{scope: SaveScope, details: array<string, mixed>}|WP_Error
|
||||
* @throws WriteFailed When the backup could not be made (nothing was saved).
|
||||
*/
|
||||
public function prepare(DiviPage $page, array $input): array|WP_Error
|
||||
{
|
||||
$templates = $this->showing($page);
|
||||
$details = ['templates' => self::listing($templates)];
|
||||
if ($templates === []) {
|
||||
return ['scope' => SaveScope::ThemeBuilderLayout, 'details' => $details];
|
||||
}
|
||||
|
||||
$id = (int) $page->post->ID;
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(
|
||||
sprintf(
|
||||
'Theme-Builder-Layout %d ist auf der Website sichtbar (%s, siehe templates); die Änderung wirkt '
|
||||
. 'sofort. Nichts wurde gespeichert (Probelauf).',
|
||||
$id,
|
||||
self::count($templates)
|
||||
),
|
||||
$details
|
||||
);
|
||||
}
|
||||
|
||||
$refusal = $this->visible->backupRefusal(
|
||||
$page->post,
|
||||
sprintf('Theme-Builder-Layout %d', $id),
|
||||
'Revisionen aktivieren (WP_POST_REVISIONS) oder das Layout im Divi Theme Builder bearbeiten.'
|
||||
);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
return [
|
||||
'scope' => SaveScope::ThemeBuilderLayout,
|
||||
'details' => array_merge($details, $this->visible->backup($page->post)),
|
||||
];
|
||||
}
|
||||
|
||||
/** A tool's own confirm_required (lit-divi-delete) on a visible layout, extended by the templates. */
|
||||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||||
{
|
||||
$templates = Errors::codeOf($error) === 'confirm_required' ? $this->showing($page) : [];
|
||||
if ($templates === []) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
return VisibleEdits::merged(
|
||||
$error,
|
||||
sprintf('Das Layout ist auf der Website sichtbar (%s, siehe templates).', self::count($templates)),
|
||||
['templates' => self::listing($templates)]
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<Template> $templates */
|
||||
private static function count(array $templates): string
|
||||
{
|
||||
return sprintf('%d aktive %s', count($templates), count($templates) === 1 ? 'Vorlage' : 'Vorlagen');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Template> $templates
|
||||
* @return list<array{id: int, title: string, use_on: list<string>}>
|
||||
*/
|
||||
private static function listing(array $templates): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (Template $template): array => [
|
||||
'id' => $template->id,
|
||||
'title' => $template->title,
|
||||
'use_on' => $template->useOn,
|
||||
],
|
||||
$templates
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -24,6 +24,7 @@ use LIT\MCP\Guides\InstructionsBuilder;
|
||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||
@@ -95,13 +96,22 @@ final class Plugin
|
||||
&& $policy instanceof ExposurePolicy
|
||||
&& $policy->allows('lit/library-insert', $registry->abilities());
|
||||
};
|
||||
// Etappe 3b: the group "Divi Theme Builder" gates TB layouts in the Divi tools like the library group (E9).
|
||||
$themeBuilderVisible = static function () use (&$policy): bool {
|
||||
return $policy instanceof ExposurePolicy && $policy->groupEnabled(ThemeBuilderAccess::GROUP);
|
||||
};
|
||||
$themeBuilderWritable = static function () use (&$policy): bool {
|
||||
return $policy instanceof ExposurePolicy && $policy->groupWritable(ThemeBuilderAccess::GROUP);
|
||||
};
|
||||
$divi = new DiviIntegration(
|
||||
$schema,
|
||||
$changeLog,
|
||||
$libraryWritable,
|
||||
libraryVisible: $libraryVisible,
|
||||
diviWritable: $diviWritable,
|
||||
libraryInsertable: $libraryInsertable
|
||||
libraryInsertable: $libraryInsertable,
|
||||
themeBuilderWritable: $themeBuilderWritable,
|
||||
themeBuilderVisible: $themeBuilderVisible
|
||||
);
|
||||
$registry = new IntegrationRegistry(
|
||||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||||
|
||||
@@ -4345,6 +4345,92 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "divi/post-content",
|
||||
"title": "Post Content",
|
||||
"category": "module",
|
||||
"appearance": {
|
||||
"multiple": false,
|
||||
"multipleAliases": [
|
||||
"divi/fullwidth-post-content"
|
||||
],
|
||||
"themeBuilderLayouts": [
|
||||
"body"
|
||||
]
|
||||
},
|
||||
"attributes": {
|
||||
"module": {
|
||||
"settings": {
|
||||
"meta": {
|
||||
"meta": []
|
||||
},
|
||||
"advanced": {
|
||||
"html": []
|
||||
},
|
||||
"decoration": {
|
||||
"animation": [],
|
||||
"attributes": [],
|
||||
"background": [],
|
||||
"bodyFont": [],
|
||||
"border": [],
|
||||
"boxShadow": [],
|
||||
"conditions": [],
|
||||
"disabledOn": [],
|
||||
"filters": [],
|
||||
"interactions": [],
|
||||
"headingFont": [],
|
||||
"overflow": [],
|
||||
"order": [],
|
||||
"position": [],
|
||||
"scroll": [],
|
||||
"sizing": [],
|
||||
"spacing": [],
|
||||
"sticky": [],
|
||||
"transform": [],
|
||||
"zIndex": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"settings": {
|
||||
"decoration": {
|
||||
"border": {
|
||||
"groupType": "group-item",
|
||||
"item": {
|
||||
"groupSlug": "designImage",
|
||||
"priority": 10,
|
||||
"render": true,
|
||||
"component": {
|
||||
"type": "group",
|
||||
"name": "divi/border",
|
||||
"props": {
|
||||
"grouped": false,
|
||||
"fieldLabel": "Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boxShadow": {
|
||||
"groupType": "group-item",
|
||||
"item": {
|
||||
"groupSlug": "designImage",
|
||||
"priority": 15,
|
||||
"render": true,
|
||||
"component": {
|
||||
"type": "group",
|
||||
"name": "divi/box-shadow",
|
||||
"props": {
|
||||
"grouped": false,
|
||||
"fieldLabel": "Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
@@ -4887,6 +4973,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"divi/post-content": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ if (! class_exists(ModuleRegistration::class)) {
|
||||
|
||||
$wanted = [
|
||||
'section', 'row', 'column', 'text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion',
|
||||
'accordion-item', 'fullwidth-header', 'group', 'contact-form', 'contact-field',
|
||||
'accordion-item', 'fullwidth-header', 'group', 'contact-form', 'contact-field', 'post-content',
|
||||
];
|
||||
$all = ModuleRegistration::get_all_core_modules_metadata();
|
||||
$metadata = [];
|
||||
@@ -40,6 +40,7 @@ foreach ($wanted as $key) {
|
||||
'category' => $module['category'] ?? '',
|
||||
'childrenName' => $module['childrenName'] ?? null,
|
||||
'childModuleName' => $module['childModuleName'] ?? null,
|
||||
'appearance' => $module['appearance'] ?? null,
|
||||
'attributes' => $attributes,
|
||||
], static fn ($value): bool => $value !== null);
|
||||
$defaults[$module['name']] = ModuleRegistration::get_default_attrs($module['name'], 'default');
|
||||
|
||||
@@ -12,6 +12,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use Mockery;
|
||||
use Throwable;
|
||||
use WP_Post;
|
||||
@@ -100,7 +101,8 @@ final class DiviWriterFixture
|
||||
array &$failures = [],
|
||||
bool $unfiltered = false,
|
||||
?DiviPageLoader $loader = null,
|
||||
?GlobalEdits $globals = null
|
||||
?GlobalEdits $globals = null,
|
||||
?ThemeBuilderEdits $themeBuilder = null
|
||||
): DiviWriter {
|
||||
$design = self::designReader();
|
||||
|
||||
@@ -110,7 +112,8 @@ final class DiviWriterFixture
|
||||
self::log(),
|
||||
$design,
|
||||
self::composition($design, $unfiltered),
|
||||
$globals
|
||||
$globals,
|
||||
$themeBuilder
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Support;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
@@ -146,6 +148,12 @@ final class ThemeBuilderFixtures
|
||||
);
|
||||
}
|
||||
|
||||
/** A loader with the library access of LibraryFixtures and $access (default: full TB rights, empty live list). */
|
||||
public static function loader(?ThemeBuilderAccess $access = null): DiviPageLoader
|
||||
{
|
||||
return new DiviPageLoader(DiviFixtures::schema(), new Parser(), LibraryFixtures::access(), $access ?? self::access());
|
||||
}
|
||||
|
||||
/**
|
||||
* An excerpt of et_theme_builder_get_flat_template_settings_options() as the adapter returns it
|
||||
* (live DDEV dump 2026-09-27 plus the Pods type lit_person).
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -76,4 +78,19 @@ final class StructureRulesTest extends TestCase
|
||||
self::assertFalse($rules->isPlainModule('divi/accordion-item'));
|
||||
self::assertFalse($rules->isPlainModule('divi/row'));
|
||||
}
|
||||
|
||||
/** F22: Divi renders divi/post-content only in body layouts (appearance.themeBuilderLayouts). */
|
||||
public function testModulesLimitedToThemeBuilderAreas(): void
|
||||
{
|
||||
$catalog = DiviFixtures::catalog();
|
||||
$column = Block::create('divi/column');
|
||||
|
||||
self::assertNull((new StructureRules($catalog, 'body'))->violation($column, 'divi/post-content'));
|
||||
self::assertSame(
|
||||
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
|
||||
(new StructureRules($catalog, 'header'))->violation($column, 'divi/post-content')
|
||||
);
|
||||
self::assertNull((new StructureRules($catalog))->violation($column, 'divi/post-content'), 'pages: Divi decides');
|
||||
self::assertNull((new StructureRules($catalog, 'footer'))->violation($column, 'divi/text'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TreeEditorTest extends TestCase
|
||||
@@ -220,4 +222,26 @@ final class TreeEditorTest extends TestCase
|
||||
$editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* F22 for stored nodes (node-copy, library-insert, duplicate): a copied section that holds divi/post-content
|
||||
* is refused outside a body layout, however deep the module sits.
|
||||
*/
|
||||
public function testInsertChecksTheThemeBuilderAreaOfNestedModules(): void
|
||||
{
|
||||
$body = (new Parser())->parse(DiviFixtures::markup('stage3/tb-body--body-mit-dynamic-content.html'));
|
||||
$section = NodeIndex::build($body)->find('s0')?->copy();
|
||||
self::assertInstanceOf(Block::class, $section);
|
||||
|
||||
$header = (new Parser())->parse(DiviFixtures::markup('stage3/tb-header--globaler-header.html'));
|
||||
$headerIndex = NodeIndex::build($header);
|
||||
$editor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'header'));
|
||||
self::assertSame(
|
||||
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
|
||||
$editor->insert($editor->position($headerIndex, []), [$section->copy()])
|
||||
);
|
||||
|
||||
$bodyEditor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'body'));
|
||||
self::assertNull($bodyEditor->insert($bodyEditor->position(NodeIndex::build($body), []), [$section->copy()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
@@ -159,4 +160,37 @@ final class DiviPageLoaderTest extends TestCase
|
||||
|
||||
self::assertToolError(LibraryFixtures::loader($hidden)->load(1058, false), 'not_found');
|
||||
}
|
||||
|
||||
public function testThemeBuilderLayoutsNeedTheThemeBuilderRole(): void
|
||||
{
|
||||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||||
LibraryFixtures::given([$body]);
|
||||
|
||||
$page = ThemeBuilderFixtures::loader()->load(1062, true);
|
||||
self::assertInstanceOf(DiviPage::class, $page);
|
||||
self::assertTrue($page->isThemeBuilderLayout());
|
||||
self::assertFalse($page->isLibraryItem());
|
||||
self::assertSame('body', $page->themeBuilderArea());
|
||||
self::assertNull(ThemeBuilderFixtures::loader()->writable($page), 'no Divi-enabled check for TB layouts');
|
||||
|
||||
self::assertToolError(
|
||||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access(['theme_builder']))->load(1062, false),
|
||||
'not_found'
|
||||
);
|
||||
self::assertToolError(
|
||||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access([], true, null, false))->load(1062, false),
|
||||
'not_found'
|
||||
);
|
||||
self::assertToolError(
|
||||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access([], false))->load(1062, true),
|
||||
'forbidden'
|
||||
);
|
||||
// Without a Theme Builder access the layout is no managed content (PostGuard; ContentSchema::EXCLUDED).
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->with('et_body_layout')->andReturn(false);
|
||||
self::assertToolError(
|
||||
(new DiviPageLoader($schema, new Parser(), LibraryFixtures::access()))->load(1062, false),
|
||||
'not_found'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use Throwable;
|
||||
use WP_Post;
|
||||
|
||||
/** The Divi write tools on Theme Builder layouts (E1, Task 5). */
|
||||
final class DiviWriterThemeBuilderTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $saved = [];
|
||||
|
||||
/** @var list<Throwable> */
|
||||
private array $failures = [];
|
||||
|
||||
private WP_Post $body;
|
||||
|
||||
private WP_Post $header;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||||
$this->header = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER);
|
||||
LibraryFixtures::given([$this->body, $this->header]);
|
||||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||||
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||||
}
|
||||
|
||||
/** @param list<Template> $templates */
|
||||
private function writer(array $templates, ?VisibleEdits $visible = null): DiviWriter
|
||||
{
|
||||
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates));
|
||||
|
||||
return DiviWriterFixture::writer(
|
||||
$this->saved,
|
||||
$this->failures,
|
||||
false,
|
||||
ThemeBuilderFixtures::loader($access),
|
||||
null,
|
||||
$visible === null ? new ThemeBuilderEdits($access) : new ThemeBuilderEdits($access, $visible)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra @return array<string, mixed> */
|
||||
private function insert(WP_Post $post, array $extra): array
|
||||
{
|
||||
return array_merge([
|
||||
'post_id' => (int) $post->ID,
|
||||
'tree_rev' => Rev::of((string) $post->post_content),
|
||||
'parent' => 's0.r0.c0',
|
||||
'nodes' => [['type' => 'text', 'content' => 'Zusatz']],
|
||||
], $extra);
|
||||
}
|
||||
|
||||
public function testInsertIntoALiveLayoutIsADryRunFirst(): void
|
||||
{
|
||||
$ability = new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
|
||||
|
||||
$dry = $ability->execute($this->insert($this->body, []));
|
||||
self::assertToolError($dry, 'confirm_required');
|
||||
self::assertSame([], $this->saved);
|
||||
|
||||
$result = $ability->execute($this->insert($this->body, ['confirm' => true]));
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertCount(1, $this->saved);
|
||||
self::assertSame(1066, $result['templates'][0]['id']);
|
||||
}
|
||||
|
||||
public function testLayoutOfADisabledTemplateSavesAtOnce(): void
|
||||
{
|
||||
$result = (new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template()])))
|
||||
->execute($this->insert($this->body, []));
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertSame([], $result['templates']);
|
||||
self::assertStringContainsString('Zusatz', $this->saved[0]);
|
||||
self::assertSame('https://kunde.test/wp-admin/admin.php?page=et_theme_builder', $result['edit_url'] ?? null);
|
||||
}
|
||||
|
||||
public function testDeleteInALiveLayoutAsksOnceForBoth(): void
|
||||
{
|
||||
$ability = new DiviDeleteAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
|
||||
$input = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $this->body->post_content), 'ids' => ['s0.r0.c0.m1']];
|
||||
|
||||
$dry = $ability->execute($input);
|
||||
self::assertToolError($dry, 'confirm_required');
|
||||
self::assertArrayHasKey('templates', $dry->get_error_data());
|
||||
|
||||
$result = $ability->execute($input + ['confirm' => true]);
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertCount(1, $this->saved);
|
||||
}
|
||||
|
||||
public function testPostContentOnlyInTheBody(): void
|
||||
{
|
||||
$ability = new DiviInsertAbility($this->writer([]));
|
||||
$postContent = ['nodes' => [['type' => 'post-content']]];
|
||||
|
||||
self::assertToolError($ability->execute($this->insert($this->header, $postContent)), 'validation_failed');
|
||||
self::assertTrue($ability->execute($this->insert($this->body, $postContent))['ok']);
|
||||
}
|
||||
|
||||
/** Ruling Q2 / P14: a failed save of a live layout after its backup names the backup. */
|
||||
public function testAFailedSaveAfterTheBackupNamesIt(): void
|
||||
{
|
||||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||||
$this->failures = [WriteFailed::rolledBack(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1062)];
|
||||
$backups = 0;
|
||||
$visible = new VisibleEdits(static function (WP_Post $post) use (&$backups): int {
|
||||
++$backups;
|
||||
|
||||
return 1071;
|
||||
}, static fn (): bool => true);
|
||||
$ability = new DiviInsertAbility(
|
||||
$this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])], $visible)
|
||||
);
|
||||
|
||||
try {
|
||||
$ability->execute($this->insert($this->body, ['confirm' => true]));
|
||||
self::fail('WriteFailed expected');
|
||||
} catch (WriteFailed $e) {
|
||||
$error = $e->toError();
|
||||
}
|
||||
|
||||
self::assertSame(1, $backups);
|
||||
self::assertSame(1071, $error->get_error_data()['backup_id']);
|
||||
self::assertStringContainsString('Sicherung 1071 liegt im Papierkorb', $error->get_error_data()['hint']);
|
||||
self::assertStringContainsString('Theme-Builder-Layouts', $error->get_error_data()['hint']);
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** Without a ThemeBuilderEdits (no Theme Builder wiring) a layout is never written. */
|
||||
public function testWithoutThemeBuilderEditsLayoutsAreNotWritten(): void
|
||||
{
|
||||
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([]));
|
||||
$writer = DiviWriterFixture::writer($this->saved, $this->failures, false, ThemeBuilderFixtures::loader($access));
|
||||
|
||||
self::assertToolError((new DiviInsertAbility($writer))->execute($this->insert($this->body, [])), 'unsupported');
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** lit/divi-replace-text never sends confirm: a live layout only answers with its templates. */
|
||||
public function testReplaceTextLeavesALiveLayoutAndNamesItsTemplates(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->justReturn(
|
||||
(object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]
|
||||
);
|
||||
$access = ThemeBuilderFixtures::access(
|
||||
[],
|
||||
true,
|
||||
ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065, ['enabled' => true])])
|
||||
);
|
||||
$loader = ThemeBuilderFixtures::loader($access);
|
||||
$writer = DiviWriterFixture::writer($this->saved, $this->failures, false, $loader, null, new ThemeBuilderEdits($access));
|
||||
$ability = new DiviReplaceTextAbility(DiviFixtures::schema(), $loader, $writer, Mockery::mock(\wpdb::class)->makePartial());
|
||||
|
||||
$result = $ability->execute([
|
||||
'search' => 'Globaler Header der Standard-Vorlage',
|
||||
'replace' => 'Kopfzeile',
|
||||
'dry_run' => false,
|
||||
'confirm' => true,
|
||||
'targets' => [['post_id' => 1061, 'tree_rev' => Rev::of((string) $this->header->post_content)]],
|
||||
]);
|
||||
|
||||
self::assertIsArray($result, $result instanceof \WP_Error ? $result->get_error_message() : '');
|
||||
$entry = $result['results'][0];
|
||||
self::assertFalse($entry['ok']);
|
||||
self::assertStringContainsString('Theme-Builder-Layout 1061', $entry['error']);
|
||||
self::assertStringContainsString('lit-divi-update', $entry['error']);
|
||||
self::assertStringNotContainsString('Globale Bibliothekseinträge', $entry['error']);
|
||||
self::assertSame(1065, $entry['templates'][0]['id']);
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SaveScopeTest extends TestCase
|
||||
{
|
||||
/** Finding 4: TB layouts carry Divi's flags after a builder save; a layout change shows site-wide (§5.6). */
|
||||
public function testThemeBuilderLayoutsGetFlagsAndClearEveryPage(): void
|
||||
{
|
||||
self::assertTrue(SaveScope::ThemeBuilderLayout->setsFlags());
|
||||
self::assertTrue(SaveScope::ThemeBuilderLayout->clearsSiteCss());
|
||||
self::assertTrue(SaveScope::Page->setsFlags());
|
||||
self::assertFalse(SaveScope::Page->clearsSiteCss());
|
||||
self::assertFalse(SaveScope::LibraryItem->setsFlags());
|
||||
}
|
||||
}
|
||||
@@ -239,4 +239,12 @@ final class ModuleCatalogTest extends TestCase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testThemeBuilderAreasFromDivisAppearance(): void
|
||||
{
|
||||
$catalog = DiviFixtures::catalog();
|
||||
|
||||
self::assertSame(['body'], $catalog->themeBuilderAreas('divi/post-content'));
|
||||
self::assertNull($catalog->themeBuilderAreas('divi/text'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Post;
|
||||
|
||||
final class ThemeBuilderEditsTest extends TestCase
|
||||
{
|
||||
private int $backups = 0;
|
||||
|
||||
private bool $trash = true;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
}
|
||||
|
||||
private static function page(int $id, string $area): DiviPage
|
||||
{
|
||||
$post = ThemeBuilderFixtures::layout($id);
|
||||
$document = (new Parser())->parse((string) $post->post_content);
|
||||
|
||||
return new DiviPage($post, $document, NodeIndex::build($document), false, $area);
|
||||
}
|
||||
|
||||
/** @param list<Template> $templates */
|
||||
private static function edits(array $templates): ThemeBuilderEdits
|
||||
{
|
||||
return new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)));
|
||||
}
|
||||
|
||||
/**
|
||||
* With a recording backup (ID 1071) and the trash switch of $this->trash.
|
||||
*
|
||||
* @param list<Template> $templates
|
||||
*/
|
||||
private function recording(array $templates): ThemeBuilderEdits
|
||||
{
|
||||
return new ThemeBuilderEdits(
|
||||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)),
|
||||
new VisibleEdits(
|
||||
function (WP_Post $post): int {
|
||||
++$this->backups;
|
||||
|
||||
return 1071;
|
||||
},
|
||||
fn (): bool => $this->trash
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testLiveLayoutNeedsConfirmAndNamesTheTemplates(): void
|
||||
{
|
||||
$edits = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
|
||||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||||
|
||||
$dry = $edits->prepare($page, []);
|
||||
self::assertToolError($dry, 'confirm_required');
|
||||
self::assertStringContainsString('sichtbar', self::clientError($dry)['message']);
|
||||
self::assertSame(
|
||||
[['id' => 1066, 'title' => '[Fixture] Vorlage Globale Instanzen', 'use_on' => ['singular:post_type:page:id:1053']]],
|
||||
$dry->get_error_data()['templates']
|
||||
);
|
||||
|
||||
$confirmed = $edits->prepare($page, ['confirm' => true]);
|
||||
self::assertSame(SaveScope::ThemeBuilderLayout, $confirmed['scope']);
|
||||
self::assertCount(1, $confirmed['details']['templates']);
|
||||
}
|
||||
|
||||
/** The global header: the default template shows it once it is enabled. */
|
||||
public function testTheDefaultTemplatesHeaderIsLiveWhenItIsEnabled(): void
|
||||
{
|
||||
$page = self::page(ThemeBuilderFixtures::HEADER, 'header');
|
||||
|
||||
self::assertSame([], self::edits([ThemeBuilderFixtures::template(1065)])->showing($page));
|
||||
self::assertCount(1, self::edits([ThemeBuilderFixtures::template(1065, ['enabled' => true])])->showing($page));
|
||||
}
|
||||
|
||||
public function testLayoutsOfDisabledOrDetachedTemplatesSaveAtOnce(): void
|
||||
{
|
||||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||||
$prepared = self::edits([
|
||||
ThemeBuilderFixtures::template(1066),
|
||||
ThemeBuilderFixtures::template(1067, ['enabled' => true, 'attached' => false]),
|
||||
])->prepare($page, []);
|
||||
|
||||
self::assertSame(SaveScope::ThemeBuilderLayout, $prepared['scope']);
|
||||
self::assertSame(['templates' => []], $prepared['details']);
|
||||
}
|
||||
|
||||
/** Ruling Q8: attached and enabled is live, whatever the template's post status (fail closed). */
|
||||
public function testATrashedButEnabledTemplateStillNeedsConfirm(): void
|
||||
{
|
||||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||||
$edits = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash'])]);
|
||||
|
||||
self::assertToolError($edits->prepare($page, []), 'confirm_required');
|
||||
}
|
||||
|
||||
public function testNoticeExtendsAToolsOwnConfirm(): void
|
||||
{
|
||||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||||
$own = Errors::confirmRequired('3 Knoten löschen? Nichts wurde gelöscht.', ['ids' => ['s0']]);
|
||||
|
||||
$merged = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])])->notice($page, $own);
|
||||
|
||||
self::assertToolError($merged, 'confirm_required');
|
||||
self::assertStringContainsString('3 Knoten löschen?', self::clientError($merged)['message']);
|
||||
self::assertStringContainsString('sichtbar', self::clientError($merged)['message']);
|
||||
self::assertSame(['s0'], $merged->get_error_data()['ids']);
|
||||
self::assertCount(1, $merged->get_error_data()['templates']);
|
||||
self::assertSame($own, self::edits([])->notice($page, $own));
|
||||
}
|
||||
|
||||
/** Ruling Q2: a confirmed live edit gets a backup when revisions are off, like a global entry. */
|
||||
public function testWithoutRevisionsAConfirmedLiveEditMakesABackupFirst(): void
|
||||
{
|
||||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||||
$edits = $this->recording([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
|
||||
|
||||
self::assertToolError($edits->prepare($page, []), 'confirm_required');
|
||||
self::assertSame(0, $this->backups, 'no backup in the dry run');
|
||||
|
||||
$confirmed = $edits->prepare($page, ['confirm' => true]);
|
||||
self::assertSame(1071, $confirmed['details']['backup_id']);
|
||||
self::assertSame(1, $this->backups);
|
||||
}
|
||||
|
||||
public function testWithRevisionsNoBackupIsMade(): void
|
||||
{
|
||||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||||
$confirmed = $this->recording([ThemeBuilderFixtures::template(1066, ['enabled' => true])])
|
||||
->prepare($page, ['confirm' => true]);
|
||||
|
||||
self::assertArrayNotHasKey('backup_id', $confirmed['details']);
|
||||
self::assertSame(0, $this->backups);
|
||||
}
|
||||
|
||||
/** A layout nobody sees needs no backup (like a plain library entry): the revision, if any, is enough. */
|
||||
public function testALayoutNobodySeesGetsNoBackup(): void
|
||||
{
|
||||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||||
$prepared = $this->recording([ThemeBuilderFixtures::template(1066)])
|
||||
->prepare(self::page(ThemeBuilderFixtures::BODY, 'body'), []);
|
||||
|
||||
self::assertSame(['templates' => []], $prepared['details']);
|
||||
self::assertSame(0, $this->backups);
|
||||
}
|
||||
|
||||
/** Ruling P9 via Q2: the backup must be faithful (unfiltered_html) and must survive (trash). */
|
||||
public function testWithoutRevisionsUnfaithfulOrLostBackupsAreRefused(): void
|
||||
{
|
||||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||||
$live = [ThemeBuilderFixtures::template(1066, ['enabled' => true])];
|
||||
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
|
||||
$filtered = $this->recording($live)->prepare($page, ['confirm' => true]);
|
||||
self::assertToolError($filtered, 'unsupported');
|
||||
self::assertStringContainsString('Theme-Builder-Layout 1062', $filtered->get_error_message());
|
||||
self::assertStringContainsString('Theme Builder', (string) self::clientError($filtered)['hint']);
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$this->trash = false;
|
||||
$trash = $this->recording($live)->prepare($page, ['confirm' => true]);
|
||||
self::assertToolError($trash, 'unsupported');
|
||||
self::assertStringContainsString('Papierkorb', $trash->get_error_message());
|
||||
|
||||
self::assertSame(0, $this->backups);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user