{ valid: bool, block_count: int, issues: string[] } * @since 1.1.0 */ public function validate(string $markup): array { $issues = []; $blocks = parse_blocks($markup); $namedBlocks = $this->countNamedBlocks($blocks); if (0 === $namedBlocks) { // Same wording as the write abilities so the model recognizes it as // the error a write call would return. $issues[] = __('No valid blocks found in the provided block markup.', 'agent-mod'); } foreach ($this->strayContentExcerpts($blocks) as $excerpt) { $issues[] = sprintf( /* translators: %s: excerpt of the stray content. */ __('Content exists outside block delimiters and will be dropped on write: "%s".', 'agent-mod'), mb_substr($excerpt, 0, 80) ); } foreach ($this->invalidJsonAttributes($markup) as $attributeJson) { $issues[] = sprintf( /* translators: %s: excerpt of the invalid attribute object. */ __('Invalid JSON attribute object: "%s" — attributes must be strict JSON: double-quoted keys and strings, no trailing commas, no single quotes.', 'agent-mod'), mb_substr($attributeJson, 0, 80) ); } $delimiterCount = preg_match_all('##s', '', $markup); foreach (self::BALANCED_TAGS as $tag) { $opened = preg_match_all('#<' . $tag . '(?![\w-])#i', $html); $closed = preg_match_all('##i', $html); if ($opened !== $closed) { $issues[] = sprintf( /* translators: 1: tag name, 2: number of opening tags, 3: number of closing tags. */ __('Unbalanced HTML: <%1$s> is opened %2$d times but closed %3$d times — every opened tag must be closed inside its own block.', 'agent-mod'), $tag, (int) $opened, (int) $closed ); } } return $issues; } /** * Returns the raw attribute-JSON excerpts that fail strict JSON decoding. * * parse_blocks() silently nulls the attributes when the JSON is malformed * while still recognizing the block, so the editor later fails validation — * every attribute object must be scanned directly. Serialized attributes can * never contain "-->" (the serializer escapes "--"). * * @param string $markup Full serialized block markup. * * @return array Offending attribute-object strings; empty when all decode. * @since 1.1.0 */ public function invalidJsonAttributes(string $markup): array { $invalid = []; if (preg_match_all('##s', $markup, $matches)) { foreach ($matches[1] as $attributeJson) { json_decode($attributeJson); if (JSON_ERROR_NONE !== json_last_error()) { $invalid[] = $attributeJson; } } } return $invalid; } /** * Returns the trimmed content of top-level nodes that carry text but no * block name — i.e. content sitting outside block delimiters that would be * silently dropped on save. * * @param array> $blocks parse_blocks() output. * * @return array Offending content excerpts; empty when none. * @since 1.1.0 */ public function strayContentExcerpts(array $blocks): array { $stray = []; foreach ($blocks as $block) { if (empty($block['blockName']) && '' !== trim((string) $block['innerHTML'])) { $stray[] = trim((string) $block['innerHTML']); } } return $stray; } /** * Checks that style attributes are mirrored by the classes the block * serializer would emit ("attribute → class parity"). * * A missing companion class (e.g. `has-border-color` when * `style.border.color` is set) is the single most common cause of the * editor's "unexpected or invalid content" recovery dialog, and nothing * on the write path detects it. * * @param array> $blocks parse_blocks() output. * * @return array Hard issues. * @since 1.1.0 */ private function checkAttributeClassParity(array $blocks): array { $issues = []; foreach ($blocks as $block) { if (! empty($block['innerBlocks'])) { $issues = array_merge($issues, $this->checkAttributeClassParity($block['innerBlocks'])); } if (empty($block['blockName']) || '' === trim((string) $block['innerHTML'])) { continue; } $attrs = is_array($block['attrs']) ? $block['attrs'] : []; $html = (string) $block['innerHTML']; $required = []; if (! empty($attrs['textColor'])) { $required[] = 'has-' . $attrs['textColor'] . '-color'; $required[] = 'has-text-color'; } if (! empty($attrs['backgroundColor'])) { $required[] = 'has-' . $attrs['backgroundColor'] . '-background-color'; $required[] = 'has-background'; } if (! empty($attrs['gradient'])) { $required[] = 'has-' . $attrs['gradient'] . '-gradient-background'; $required[] = 'has-background'; } if (! empty($attrs['fontSize'])) { $required[] = 'has-' . $attrs['fontSize'] . '-font-size'; } if (! empty($attrs['fontFamily'])) { $required[] = 'has-' . $attrs['fontFamily'] . '-font-family'; } if (! empty($attrs['borderColor'])) { $required[] = 'has-border-color'; $required[] = 'has-' . $attrs['borderColor'] . '-border-color'; } if (! empty($attrs['style']['color']['text'])) { $required[] = 'has-text-color'; } if (! empty($attrs['style']['color']['background']) || ! empty($attrs['style']['color']['gradient'])) { $required[] = 'has-background'; } if (! empty($attrs['style']['border']['color'])) { $required[] = 'has-border-color'; } if (! empty($attrs['style']['typography']['textAlign'])) { $required[] = 'has-text-align-' . $attrs['style']['typography']['textAlign']; } if (! empty($attrs['align']) && in_array($attrs['align'], ['wide', 'full'], true)) { $required[] = 'align' . $attrs['align']; } if (! empty($attrs['className']) && is_string($attrs['className'])) { foreach (preg_split('/\s+/', trim($attrs['className'])) as $customClass) { if ('' !== $customClass) { $required[] = $customClass; } } } foreach (array_unique($required) as $class) { if (! preg_match('/(?> $blocks parse_blocks() output. * * @return array Warnings (never flip validity). * @since 1.1.0 */ private function checkLegacyAlignmentAttributes(array $blocks): array { $issues = []; foreach ($blocks as $block) { if (! empty($block['innerBlocks'])) { $issues = array_merge($issues, $this->checkLegacyAlignmentAttributes($block['innerBlocks'])); } if (empty($block['blockName']) || ! in_array($block['blockName'], ['core/paragraph', 'core/heading'], true)) { continue; } $attrs = is_array($block['attrs']) ? $block['attrs'] : []; $legacy = ! empty($attrs['textAlign']) || (! empty($attrs['align']) && in_array($attrs['align'], ['left', 'center', 'right'], true)); if ($legacy) { $issues[] = sprintf( /* translators: %s: block name. */ __('warning: "%s" uses a legacy top-level alignment attribute ("align"/"textAlign") — this editor stores text alignment as {"style":{"typography":{"textAlign":"…"}}} with the has-text-align-… class; use that form.', 'agent-mod'), $block['blockName'] ); } } return $issues; } /** * Counts named blocks recursively. * * @param array> $blocks parse_blocks() output. * * @return int * @since 1.1.0 */ private function countNamedBlocks(array $blocks): int { $count = 0; foreach ($blocks as $block) { if (! empty($block['blockName'])) { $count++; } if (! empty($block['innerBlocks'])) { $count += $this->countNamedBlocks($block['innerBlocks']); } } return $count; } /** * Collects the unique block names used in a parsed block tree. * * @param array> $blocks parse_blocks() output. * * @return array * @since 1.1.0 */ private function collectBlockNames(array $blocks): array { $names = []; foreach ($blocks as $block) { if (! empty($block['blockName'])) { $names[$block['blockName']] = true; } if (! empty($block['innerBlocks'])) { foreach ($this->collectBlockNames($block['innerBlocks']) as $name) { $names[$name] = true; } } } return array_keys($names); } }