| 1 |
<?php |
| 2 |
namespace ABlocks\Classes; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
/** |
| 9 |
* Shared base for the atomic CONTAINER blocks — div, flex and grid. |
| 10 |
* |
| 11 |
* Everything about style compilation still comes from AtomicBlockBase. What |
| 12 |
* this layer adds is one front-end-only rewrite: a container carrying a Link |
| 13 |
* URL is saved as an `<a>` element (see makeContainerSave in |
| 14 |
* atomic-shared/container.js), and an `<a>` may not contain another `<a>`. |
| 15 |
* |
| 16 |
* That is not a validation nicety, it is a parser rule with teeth. Feed a |
| 17 |
* browser `<a class="…flex container"><div>…</div><div><a href>…</a></div>…</a>` |
| 18 |
* — a linked container holding a child that is itself linked, an image with a |
| 19 |
* link being the everyday way to get one — and the HTML5 adoption agency |
| 20 |
* algorithm rebuilds the tree: the outer anchor is closed at the nested one, an |
| 21 |
* EMPTY clone of it is left behind inside the child, and every sibling after |
| 22 |
* that point is lifted out of the container altogether. Parsed with a real |
| 23 |
* HTML5 parser, four children of a flex row came back as |
| 24 |
* |
| 25 |
* <a class="…"><div>1</div><div>2</div></a> |
| 26 |
* <div><a class="…"></a><a href>3</a></div> |
| 27 |
* <div>4</div> |
| 28 |
* |
| 29 |
* which is exactly the reported break: the first children still in their row |
| 30 |
* inside the bordered box, an empty bordered box after it (that clone), and the |
| 31 |
* remaining children spilled down the page at their natural size because they |
| 32 |
* are no longer in the flex container at all. The editor never showed it — it |
| 33 |
* renders the chosen htmlTag and no anchor whatsoever — so the two disagreed |
| 34 |
* only once published. |
| 35 |
* |
| 36 |
* No stylesheet can reach this: by the time CSS runs, the DOM is already the |
| 37 |
* wrong shape. So the anchor stops being the box. The container is rendered |
| 38 |
* back as its own tag, carrying every class, id, data-attribute and therefore |
| 39 |
* every compiled style and breakpoint it had, and the link becomes a |
| 40 |
* transparent overlay child stretched across it (`.ablocks-atomic-link`, sized |
| 41 |
* by the rules in each container's style.css). The whole box stays clickable, |
| 42 |
* links inside it stay clickable and are no longer torn out of the layout, and |
| 43 |
* the markup the browser is handed is valid, so the tree it builds is the tree |
| 44 |
* the editor drew. |
| 45 |
* |
| 46 |
* Done here at render rather than in save() on purpose: the saved markup of |
| 47 |
* every page already published carries the nested anchors, and a save-side fix |
| 48 |
* — even with a deprecation to keep those posts valid in the editor — would |
| 49 |
* still serve that stored markup on the front end until someone opened and |
| 50 |
* re-saved each one. Rewriting on output fixes the pages that are already |
| 51 |
* broken, today, and leaves the editor and the stored content untouched. |
| 52 |
*/ |
| 53 |
abstract class AtomicContainerBase extends AtomicBlockBase { |
| 54 |
|
| 55 |
/** |
| 56 |
* The tags the HTML-tag control offers — see TAGS in |
| 57 |
* atomic-shared/container.js. Anything else falls back to a div rather |
| 58 |
* than being echoed into the page as a tag name. |
| 59 |
*/ |
| 60 |
protected static $allowed_tags = [ 'div', 'section', 'article', 'aside', 'header', 'footer', 'main', 'nav' ]; |
| 61 |
|
| 62 |
public function render_callback( $attributes, $content, $block_instance ) { |
| 63 |
// Before the base class wraps or filters anything, so it only ever sees |
| 64 |
// the shape it would have seen for an unlinked container. |
| 65 |
$content = self::unnest_container_link( $content, $attributes ); |
| 66 |
return parent::render_callback( $attributes, $content, $block_instance ); |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Turn `<a class="container" href>…</a>` back into `<tag class="container"> |
| 71 |
* <a class="ablocks-atomic-link" href></a>…</tag>`. |
| 72 |
* |
| 73 |
* Deliberately conservative: it only acts on markup shaped the way |
| 74 |
* makeContainerSave writes it — a linked container whose first tag is the |
| 75 |
* anchor and whose last tag closes it — and returns the content untouched |
| 76 |
* on anything else, so a container that has already been rewritten, or one |
| 77 |
* whose markup came from somewhere unexpected, is left exactly as it is. |
| 78 |
* |
| 79 |
* @param string $content The block's saved markup, inner blocks rendered. |
| 80 |
* @param array $attributes The block's attributes. |
| 81 |
* @return string The rewritten markup, or $content unchanged. |
| 82 |
*/ |
| 83 |
public static function unnest_container_link( $content, $attributes ) { |
| 84 |
if ( ! is_string( $content ) || '' === $content ) { |
| 85 |
return $content; |
| 86 |
} |
| 87 |
if ( empty( $attributes['link']['url'] ) ) { |
| 88 |
return $content; |
| 89 |
} |
| 90 |
// The container's own anchor opens the markup and closes it; anything |
| 91 |
// else is not the shape this rewrite understands. |
| 92 |
if ( ! preg_match( '#^(\s*)<a\s([^>]*)>#i', $content, $open ) ) { |
| 93 |
return $content; |
| 94 |
} |
| 95 |
if ( ! preg_match( '#</a>(\s*)$#i', $content, $close ) ) { |
| 96 |
return $content; |
| 97 |
} |
| 98 |
|
| 99 |
$element_attrs = $open[2]; |
| 100 |
|
| 101 |
// The link attributes move to the overlay verbatim — they were escaped |
| 102 |
// when the block was saved, and re-encoding them here would be one more |
| 103 |
// place for that to go wrong. |
| 104 |
$link_attrs = ''; |
| 105 |
foreach ( [ 'href', 'target', 'rel' ] as $name ) { |
| 106 |
if ( preg_match( '#(^|\s)' . $name . '="([^"]*)"#i', $element_attrs, $found ) ) { |
| 107 |
$link_attrs .= ' ' . $name . '="' . $found[2] . '"'; |
| 108 |
$element_attrs = str_replace( $found[0], $found[1], $element_attrs ); |
| 109 |
} |
| 110 |
} |
| 111 |
if ( '' === $link_attrs ) { |
| 112 |
return $content; |
| 113 |
} |
| 114 |
|
| 115 |
$tag = isset( $attributes['htmlTag'] ) ? strtolower( (string) $attributes['htmlTag'] ) : 'div'; |
| 116 |
if ( ! in_array( $tag, self::$allowed_tags, true ) ) { |
| 117 |
$tag = 'div'; |
| 118 |
} |
| 119 |
|
| 120 |
// The hook the overlay is positioned against. |
| 121 |
if ( preg_match( '#(^|\s)class="([^"]*)"#i', $element_attrs, $found ) ) { |
| 122 |
$element_attrs = str_replace( |
| 123 |
$found[0], |
| 124 |
$found[1] . 'class="' . $found[2] . ' ablocks-has-link"', |
| 125 |
$element_attrs |
| 126 |
); |
| 127 |
} else { |
| 128 |
$element_attrs = 'class="ablocks-has-link" ' . $element_attrs; |
| 129 |
} |
| 130 |
// One space where the link attributes were lifted out. |
| 131 |
$element_attrs = trim( preg_replace( '/\s{2,}/', ' ', $element_attrs ) ); |
| 132 |
|
| 133 |
$inner = substr( |
| 134 |
$content, |
| 135 |
strlen( $open[0] ), |
| 136 |
strlen( $content ) - strlen( $open[0] ) - strlen( $close[0] ) |
| 137 |
); |
| 138 |
|
| 139 |
return $open[1] |
| 140 |
. '<' . $tag . ( '' !== $element_attrs ? ' ' . $element_attrs : '' ) . '>' |
| 141 |
. '<a class="ablocks-atomic-link"' . $link_attrs . self::overlay_label( $inner ) . '></a>' |
| 142 |
. $inner |
| 143 |
. '</' . $tag . '>' |
| 144 |
. $close[1]; |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* An accessible name for the overlay. |
| 149 |
* |
| 150 |
* The anchor used to BE the container, so it was named by everything inside |
| 151 |
* it; emptied out, it would announce as a bare URL and read as an unlabelled |
| 152 |
* link. This gives back roughly what a screen reader heard before — the |
| 153 |
* container's own text, or failing that the first alt text in it, which for |
| 154 |
* the common linked-image card is the only text there is. Capped, because |
| 155 |
* the name is a label and not the content. |
| 156 |
* |
| 157 |
* @param string $inner The container's inner markup. |
| 158 |
* @return string An ` aria-label="…"` attribute, or an empty string. |
| 159 |
*/ |
| 160 |
private static function overlay_label( $inner ) { |
| 161 |
$label = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $inner ) ) ); |
| 162 |
if ( '' === $label && preg_match( '#\salt="([^"]+)"#i', $inner, $found ) ) { |
| 163 |
$label = trim( $found[1] ); |
| 164 |
} |
| 165 |
if ( '' === $label ) { |
| 166 |
return ''; |
| 167 |
} |
| 168 |
return ' aria-label="' . esc_attr( mb_substr( $label, 0, 100 ) ) . '"'; |
| 169 |
} |
| 170 |
} |
| 171 |
|