fix: refuse private/unpublished menu links, write only changed menu items

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-26 00:20:34 +02:00
co-authored by Claude Opus 5.5
parent f80cfb9e44
commit a2e9dabb17
6 changed files with 355 additions and 67 deletions
@@ -66,16 +66,18 @@ final class MenuOperations
return 'Objekt erwartet.';
}
$op = $operation['op'] ?? '';
$parentId = (int) ($operation['parent_id'] ?? 0);
$parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT;
if (! isset($children[$parentKey])) {
// parent_id only matters where something is placed; remove ignores it.
if (in_array($op, ['add', 'move'], true) && ! isset($children[$parentKey])) {
return 'parent_id ist kein Eintrag dieses Menüs.';
}
$position = isset($operation['position']) ? (int) $operation['position'] : null;
$key = 'id:' . (int) ($operation['id'] ?? 0);
switch ($operation['op'] ?? '') {
switch ($op) {
case 'add':
if (! is_array($operation['item'] ?? null)) {
return 'item fehlt.';
@@ -46,12 +46,17 @@ final class MenuSetItemsAbility implements Ability
. 'Der Baum aus lit-menu-get kann bearbeitet unverändert zurückgegeben werden: bestehende Einträge '
. 'werden über id erkannt, type/object/object_id und bei post/taxonomy auch url sind nur informativ '
. 'und werden ignoriert; title wird nur bei Änderung zur eigenen Beschriftung, url nur bei custom '
. 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id (muss für dich lesbar sein) oder '
. 'custom mit url (/pfad, #anker, http(s), mailto, tel) und title, jeweils optional children. Das '
. 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id oder custom mit url (/pfad, '
. '#anker, http(s), mailto, tel) und title, jeweils optional children. Verlinkt werden nur für dich '
. 'lesbare, veröffentlichte Inhalte und Begriffe öffentlicher Taxonomien; private Inhalte nie. '
. 'Entwürfe/ausstehende/geplante Inhalte nur mit allow_unpublished: true – WordPress zeigt sie sofort '
. 'im Menü, Besucher erhalten bis zur Veröffentlichung einen toten Link. Das '
. 'Ziel eines bestehenden Eintrags ändern: entfernen und neu anlegen. Alles wird vorab geprüft, dann '
. 'ganz oder gar nicht geschrieben. confirm: true ist nur nötig, wenn dabei Einträge wegfallen – '
. 'Menüeinträge haben keinen Papierkorb und werden endgültig gelöscht. Die Antwort enthält '
. 'previous_items (Stand vorher) zum Zurückbauen.';
. 'previous_items (Stand vorher) zum Zurückbauen; entfernte Einträge lassen sich daraus nur mit '
. 'Titel, Ziel und Position neu anlegen (neue id; CSS-Klassen, Linkziel, Beschreibung gehen verloren). '
. 'Nur tatsächlich geänderte Einträge werden geschrieben.';
}
public function inputSchema(): array
@@ -81,6 +86,11 @@ final class MenuSetItemsAbility implements Ability
'additionalProperties' => false,
],
],
'allow_unpublished' => [
'type' => 'boolean',
'description' => 'Links auf Entwürfe/ausstehende/geplante Inhalte erlauben (sofort sichtbar, '
. 'bis zur Veröffentlichung toter Link).',
],
'confirm' => [
'type' => 'boolean',
'description' => 'Nur nötig, wenn Einträge entfernt werden.',
@@ -140,7 +150,7 @@ final class MenuSetItemsAbility implements Ability
return $tree;
}
$plan = $this->writer->plan($tree, $current);
$plan = $this->writer->plan($tree, $current, ($input['allow_unpublished'] ?? false) === true);
if ($plan['errors'] !== []) {
return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']);
}
@@ -160,19 +170,22 @@ final class MenuSetItemsAbility implements Ability
// The menu is saved – from here on failures are logged and reported as warnings only.
$warnings = $plan['warnings'];
$summary = sprintf(
'Menü "%s": %d neu, %d geändert, %d entfernt',
$menu->name,
$stats['created'],
$stats['updated'],
$stats['removed']
);
$logId = FollowUp::run(
$changed = array_sum($stats) > 0;
$summary = $changed
? sprintf(
'Menü "%s": %d neu, %d geändert, %d entfernt',
$menu->name,
$stats['created'],
$stats['updated'],
$stats['removed']
)
: sprintf('Menü "%s": keine Änderung', $menu->name);
$logId = $changed ? FollowUp::run(
fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist gespeichert.',
$warnings
);
) : null;
$after = FollowUp::run(
fn () => MenuTree::build(array_values($this->currentItems($menuId))),
$this->name() . ' re-read',
@@ -180,8 +193,12 @@ final class MenuSetItemsAbility implements Ability
$warnings
);
return ToolResult::ok($summary . '.')
->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name)
$result = ToolResult::ok($summary . '.');
if ($changed) {
$result->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name);
}
return $result
->warnings($warnings)
->logId(is_int($logId) ? $logId : null)
->with('items', is_array($after) ? $after : [])
+129 -34
View File
@@ -17,7 +17,8 @@ use WP_Term;
* its id alone; type/object/object_id and – for post/taxonomy items – url are
* informational and ignored. Existing items keep all their data (classes, target,
* description, custom label, …); title only becomes a custom label when it differs
* from the currently shown title, url only changes for custom items.
* from the currently shown title, url only changes for custom items. Only items whose
* parent, position, title or url actually change are rewritten (and counted as updated).
*
* apply() is all-or-nothing as far as WordPress allows: on a failure it deletes the
* items it created and rewrites the touched items with their previous data. Deleted
@@ -27,6 +28,8 @@ use WP_Term;
class MenuWriter
{
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
/** Statuses WordPress still shows in menus although visitors get a dead link until publication. */
private const UNPUBLISHED = ['draft', 'pending', 'future'];
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
private const URL_RULE = '/pfad, #anker, http(s), mailto, tel';
@@ -39,20 +42,25 @@ class MenuWriter
/** @var list<int> Items deleted by the running apply(). */
private array $deleted = [];
/** Set by plan(): links to draft/pending/future posts are allowed. */
private bool $allowUnpublished = false;
/**
* @param list<mixed> $tree
* @param array<int, object> $current Current items by ID.
* @param array<int, object> $current Current items by ID.
* @param bool $allowUnpublished Allow links to draft/pending/future posts (with a warning).
* @return array{
* errors: array<string, string>,
* removals: list<array{id: int, title: string}>,
* warnings: list<string>
* }
*/
public function plan(array $tree, array $current): array
public function plan(array $tree, array $current, bool $allowUnpublished = false): array
{
$errors = [];
$warnings = [];
$seen = [];
$this->allowUnpublished = $allowUnpublished;
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
$removals = [];
@@ -195,19 +203,13 @@ class MenuWriter
) {
return 'object_id muss ein vorhandener, für dich lesbarer Eintrag sein.';
}
if ($post->post_status !== 'publish') {
$warnings[] = sprintf(
'"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.',
$post->post_title
);
}
return null;
return $this->checkStatus($post, $warnings);
case 'taxonomy':
$term = is_int($objectId) ? get_term($objectId) : null;
return $term instanceof WP_Term
&& (is_taxonomy_viewable($term->taxonomy) || current_user_can('edit_term', $objectId))
// Terms of non-public taxonomies would show internal structure in a public menu.
return $term instanceof WP_Term && is_taxonomy_viewable($term->taxonomy)
? null
: 'object_id muss ein vorhandener, für dich lesbarer Begriff sein.';
: 'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie sein.';
case 'custom':
if (! $this->validUrl($node['url'] ?? null)) {
return 'Individueller Link braucht eine gültige url (' . self::URL_RULE . ').';
@@ -220,6 +222,45 @@ class MenuWriter
}
}
/**
* WordPress shows menu items of draft/pending/private/future posts on the live site right
* away (only trashed or missing objects are hidden), so private posts are always refused
* and unpublished ones only with allow_unpublished.
*
* @param list<string> $warnings
*/
private function checkStatus(WP_Post $post, array &$warnings): ?string
{
$status = (string) $post->post_status;
if ($status === 'publish') {
return null;
}
if ($status === 'private') {
return 'Private Inhalte dürfen nicht in öffentlichen Menüs erscheinen (Besucher sähen Titel und Link).';
}
if (! in_array($status, self::UNPUBLISHED, true)) {
return sprintf('Einträge mit Status %s können nicht verlinkt werden.', $status);
}
if (! $this->allowUnpublished) {
return sprintf(
'"%s" ist nicht veröffentlicht (%s), würde aber sofort im Menü erscheinen – als toter Link. '
. 'Erst veröffentlichen oder allow_unpublished: true setzen.',
$post->post_title,
$status
);
}
$warnings[] = sprintf(
'Eintrag "%s" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.',
$post->post_title
);
return null;
}
/** @param array<string, mixed> $node */
private function urlChanges(array $node, object $item): bool
{
@@ -234,12 +275,17 @@ class MenuWriter
private function validUrl(mixed $url): bool
{
if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) {
if (! is_string($url) || $url === '' || preg_match('/[\s\x00-\x1f\x7f]/', $url) === 1) {
return false;
}
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
return ! str_starts_with($url, '//');
if (str_starts_with($url, '/')) {
// "//host" and "/\host" are protocol-relative links to another host in browsers.
return ! in_array(substr($url, 1, 1), ['/', '\\'], true);
}
if (str_starts_with($url, '#')) {
return true;
}
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
@@ -263,22 +309,35 @@ class MenuWriter
foreach ($nodes as $node) {
$position++;
$existing = self::isExisting($node) ? $current[(int) $node['id']] : null;
$args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node);
if ($existing === null ? isset($node['title']) : $this->titleChanges($node, $existing)) {
$args['menu-item-title'] = (string) $node['title'];
}
if ($existing === null ? $node['type'] === 'custom' : $this->customUrlChanges($node, $existing)) {
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
}
$args['menu-item-parent-id'] = $parentId;
$args['menu-item-position'] = $position;
$args['menu-item-status'] = 'publish';
$itemId = $this->save($menuId, $existing !== null ? (int) $existing->ID : 0, $args);
if ($existing !== null) {
$seen[(int) $existing->ID] = true;
$this->touched[] = (int) $existing->ID;
$itemId = (int) $existing->ID;
$seen[$itemId] = true;
$titleChanges = $this->titleChanges($node, $existing);
$urlChanges = $this->customUrlChanges($node, $existing);
$moved = $parentId !== (int) $existing->menu_item_parent
|| $position !== $this->storedPosition($current, $itemId);
if ($titleChanges || $urlChanges || $moved) {
$args = $this->withNode(
$this->argsFromItem($existing),
$node,
$titleChanges,
$urlChanges,
$parentId,
$position
);
$this->save($menuId, $itemId, $args);
$this->touched[] = $itemId;
}
} else {
$args = $this->withNode(
$this->argsFromNode($node),
$node,
isset($node['title']),
$node['type'] === 'custom',
$parentId,
$position
);
$itemId = $this->save($menuId, 0, $args);
$this->created[] = $itemId;
}
@@ -286,6 +345,45 @@ class MenuWriter
}
}
/**
* @param array<string, mixed> $args
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private function withNode(
array $args,
array $node,
bool $setTitle,
bool $setUrl,
int $parentId,
int $position
): array {
if ($setTitle) {
$args['menu-item-title'] = (string) $node['title'];
}
if ($setUrl) {
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
}
$args['menu-item-parent-id'] = $parentId;
$args['menu-item-position'] = $position;
$args['menu-item-status'] = 'publish';
return $args;
}
/**
* The item's current position: its menu_order, or – when that is missing – its index in
* $current (which is in menu order).
*
* @param array<int, object> $current
*/
private function storedPosition(array $current, int $itemId): int
{
$order = (int) ($current[$itemId]->menu_order ?? 0);
return $order > 0 ? $order : (int) array_search($itemId, array_keys($current), true) + 1;
}
/** @param array<string, mixed> $node */
private function customUrlChanges(array $node, object $item): bool
{
@@ -338,14 +436,11 @@ class MenuWriter
}
}
$order = array_flip(array_keys($current));
foreach ($this->touched as $itemId) {
$item = $current[$itemId];
$args = $this->argsFromItem($item);
$args['menu-item-parent-id'] = (int) $item->menu_item_parent;
$args['menu-item-position'] = isset($item->menu_order) && (int) $item->menu_order > 0
? (int) $item->menu_order
: $order[$itemId] + 1;
$args['menu-item-position'] = $this->storedPosition($current, $itemId);
$args['menu-item-status'] = 'publish';
try {
$this->save($menuId, $itemId, $args);