Skip to content

MarkupQA: "Unable to resolve link" warning exposes internal repeater path for RepeaterMatrix fields — enrich message and/or make linkWarning() hookable #591

Description

@elabx

Short description

When editing a page that contains a RepeaterMatrix (or plain Repeater) field, the MarkupQA "wakeup" of a rich-text/textarea subfield can emit a warning for a stored link that no longer resolves. The warning identifies the field's internal repeater page path rather than the content block the editor actually sees. For example (values illustrative):

Unable to resolve link on page /processwire/repeaters/for-field-12/for-page-1041/1700000000-1234-1/ in field "Body": wakeup: /old/moved-page/

For an editor this isn't actionable — /processwire/repeaters/for-field-12/for-page-1041/1700000000-1234-1/ gives no hint about which matrix item on which page holds the broken link, and it surfaces an internal implementation path in the UI.

Setup

  • Observed on ProcessWire 3.0.262; MarkupQA::linkWarning() is unchanged on current dev (3.0.267) and master — still protected, same message
  • PHP 7.4
  • Field: FieldtypeTextarea (CKEditor) inside a RepeaterMatrix, with link abstraction enabled

Where it comes from

FieldtypeTextarea::___wakeupValue()MarkupQA::wakeupLinks()MarkupQA::linkWarning() in wire/core/Tools/MarkupQA.php, which builds the message and calls $this->warning():

protected function linkWarning($path, $logWarning = true) {
    if($this->wire()->page->template == 'admin' && $this->wire()->process == 'ProcessPageEdit') {
        $this->warning(sprintf(
            $this->_('Unable to resolve link on page %1$s in field "%2$s": %3$s'),
            $this->page->path,
            $this->field->getLabel(),
            $path
        ));
    }
    ...
}

Suggested improvement

Two options, not mutually exclusive:

1. Enrich the message for repeater pages in core. When $this->page is a repeater page, core already has the context to render something the editor recognises — host page, field, and (for matrix) the item type + index. For example:

Broken link /old/moved-page/ in Text & Image item #3 → field "Body" on page About Us

2. Make MarkupQA::linkWarning() hookable (rename to ___linkWarning()), ideally passing structured context — $this->page, $this->field, and the unresolved $path — as arguments. Today the method is protected and Wire::warning() isn't hookable, so there's no clean seam to customise or suppress these warnings. The only option is to post-process rendered notice text, which is brittle because it depends on the translated message format.

Nested repeaters. The repeater path is always flat — for-page-{ownerId} is the immediate owner, which for a nested repeater is another repeater page, not the content page. Core already has RepeaterPage::getForPageRoot() / getForFieldRoot() (3.0.132+) to resolve the real root page, so enriching in core (or exposing the page/field via a hook) handles nesting correctly for free. A text-parsing workaround has to re-resolve the repeater page and call getForPageRoot() itself.

Related limitation: detection is render-time only

Worth noting because it bounds what any message-enrichment can achieve: these warnings are a side effect of MarkupQA::wakeupLinks(), which only runs when a field value is woken up. RepeaterMatrix items that load lazily/asynchronously (fetched via AJAX only when the item is opened) are not woken on initial page-edit load, so no warning is emitted for a broken link inside them until that item is opened and the page saved.

So both the raw warning and any enrichment of it cover only the items that happened to be woken during the request. Complete coverage would require a proactive scan of repeater/matrix subfields (or an eager-wakeup option), independent of render-time wakeup — which is feasible in core but not in a notice-rewriting hook.

Current workaround (fragile — illustrates why a hook is needed)

With no hook available, we rewrite the notice text after the edit form is built, parsing the repeater path back out of the message and resolving the item. This is exactly the kind of reverse-engineering a hookable linkWarning() would make unnecessary:

// site/ready.php
$wire->addHookAfter('ProcessPageEdit::execute', function (HookEvent $event) {
    $sanitizer = $event->wire('sanitizer');
    $pages = $event->wire('pages');

    foreach ($event->wire('notices') as $notice) {
        if (!($notice instanceof NoticeWarning)) continue;
        $text = $notice->text;
        if (strpos($text, '/repeaters/for-field-') === false) continue;
        if (!preg_match('{/repeaters/for-field-\d+/for-page-\d+/([^/\s"]+)}', $text, $m)) continue;

        // This is purely cosmetic, so never let it break the edit screen: any
        // unexpected failure leaves the original warning untouched.
        try {
            // Resolve the repeater page, then use its own API so nested repeaters
            // (whose for-page owner is another repeater page) attribute correctly.
            $repeater = $pages->get('name=' . $sanitizer->pageName($m[1]) . ', include=all');
            if (!$repeater->id || !method_exists($repeater, 'getForField')) continue;

            $repeaterField = $repeater->getForField();        // the repeater/matrix field
            $ownerPage = $repeater->getForPage();             // immediate owner (may be a repeater page)
            $rootPage = method_exists($repeater, 'getForPageRoot') ? $repeater->getForPageRoot() : $ownerPage;
            if (!$repeaterField || !$repeaterField->id || !$rootPage->id) continue;

            // Recover the subfield label and broken target from the original message.
            $fieldLabel = preg_match('/in field "([^"]+)"/', $text, $fm) ? $fm[1] : '';
            $detail = preg_match('/in field "[^"]+":\s*(.+)$/s', $text, $dm) ? trim($dm[1]) : '';
            $detail = preg_replace('/^wakeup:\s*/', '', $detail);

            // Matrix type label, when this is a RepeaterMatrix item.
            $typeLabel = '';
            if (method_exists($repeater, 'getMatrixType') && method_exists($repeaterField, 'getMatrixTypeLabel')) {
                $typeNum = (int) $repeater->getMatrixType(true);
                if ($typeNum) $typeLabel = (string) $repeaterField->getMatrixTypeLabel($typeNum);
            }

            // Item number = position within its immediate owner's field value.
            $itemNo = '';
            $items = $ownerPage->get($repeaterField->name);
            if ($items instanceof PageArray) {
                $i = 0;
                foreach ($items as $it) {
                    $i++;
                    if ($it->id === $repeater->id) { $itemNo = $i; break; }
                }
            }

            $where = $sanitizer->entities($typeLabel !== '' ? $typeLabel : (string) $repeaterField->getLabel());
            if ($itemNo !== '') $where .= " item #$itemNo";

            $notice->text = sprintf(
                'Broken link <code>%s</code> in <b>%s</b> &rarr; field "%s" on page <b>%s</b>',
                $sanitizer->entities($detail !== '' ? $detail : '(unknown)'),
                $where,
                $sanitizer->entities($fieldLabel),
                $sanitizer->entities($rootPage->title ?: $rootPage->path)
            );
            $notice->flags = $notice->flags | Notice::allowMarkup;
        } catch (\Throwable $e) {
            // leave the original notice as-is
        }
    }
});

Result (illustrative):

Broken link /old/moved-page/ in Text & Image item #3 → field "Body" on page About Us

For a plain (non-matrix) repeater there's no per-item type, so it falls back to the repeater field's own label:

Broken link /old/moved-page/ in Content Sections item #3 → field "Body" on page About Us

Note: an "edit this item" deep-link was considered but dropped — editing a RepeaterMatrix page standalone renders all matrix types' fields (per-type field visibility is applied by InputfieldRepeaterMatrix, not the page template), so there's no clean way to open just that one item.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions