PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.0
Elementor Website Builder – more than just a page builder v4.3.0
4.3.2 4.3.1 4.3.0 4.3.0-beta3 4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 All 455 releases
elementor / modules / atomic-widgets / elements / atomic-accordion / atomic-accordion.php

atomic-accordion.php in Elementor Website Builder – more than just a page builder 4.3.0, at modules/atomic-widgets/elements/atomic-accordion/atomic-accordion.php

599 lines 23.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Elementor\Modules\AtomicWidgets\Elements\Atomic_Accordion;
4
5 use Elementor\Core\Utils\Collection;
6 use Elementor\Modules\AtomicWidgets\Controls\Section;
7 use Elementor\Modules\AtomicWidgets\Controls\Types\Elements\Accordion_Items_Control;
8 use Elementor\Modules\AtomicWidgets\Controls\Types\Switch_Control;
9 use Elementor\Modules\AtomicWidgets\Controls\Types\Text_Control;
10 use Elementor\Modules\AtomicWidgets\Controls\Types\Toggle_Control;
11 use Elementor\Modules\AtomicWidgets\Elements\Atomic_Paragraph\Atomic_Paragraph;
12 use Elementor\Modules\AtomicWidgets\Elements\Base\Atomic_Element_Base;
13 use Elementor\Modules\AtomicWidgets\Elements\Base\Element_Builder;
14 use Elementor\Modules\AtomicWidgets\Elements\Base\Has_Element_Template;
15 use Elementor\Modules\AtomicWidgets\Elements\Base\Html_Tag_Computer;
16 use Elementor\Modules\AtomicWidgets\Elements\Loader\Frontend_Assets_Loader;
17 use Elementor\Modules\AtomicWidgets\PropTypes\Attributes_Prop_Type;
18 use Elementor\Modules\AtomicWidgets\PropTypes\Classes_Prop_Type;
19 use Elementor\Modules\AtomicWidgets\PropTypes\Escaped_Html_Prop_Type;
20 use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\Boolean_Prop_Type;
21 use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\String_Prop_Type;
22 use Elementor\Modules\AtomicWidgets\PropTypes\Size_Prop_Type;
23 use Elementor\Modules\AtomicWidgets\Styles\Style_Definition;
24 use Elementor\Modules\AtomicWidgets\Styles\Style_Variant;
25 use Elementor\Modules\Components\PropTypes\Overridable_Prop_Type;
26 use Elementor\Plugin;
27 use Elementor\Utils;
28
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit; // Exit if accessed directly.
31 }
32
33 class Atomic_Accordion extends Atomic_Element_Base {
34 use Has_Element_Template;
35
36 const BASE_STYLE_KEY = 'base';
37
38 const ELEMENT_TYPE_ITEM = 'e-accordion-item';
39 const ELEMENT_TYPE_HEADER = 'e-accordion-item-header';
40 const ELEMENT_TYPE_TITLE = 'e-accordion-item-title';
41 const ELEMENT_TYPE_ICON = 'e-accordion-item-icon';
42 const ELEMENT_TYPE_CONTENT = 'e-accordion-item-content';
43
44 const DEFAULT_ITEM_COUNT = 2;
45
46 public static $widget_description = 'Create collapsible content sections using native <details>/<summary> semantics, with no JavaScript needed for the toggle. Structure: e-accordion contains e-accordion-item elements; each item contains an e-accordion-item-header (holding e-accordion-item-title and an optional e-accordion-item-icon) and an e-accordion-item-content that accepts any element.';
47
48 public function __construct( $data = [], $args = null ) {
49 parent::__construct( $data, $args );
50 $this->meta( 'is_container', true );
51 $this->meta( 'is_compound', true );
52 }
53
54 public static function get_type() {
55 return 'e-accordion';
56 }
57
58 public static function get_element_type(): string {
59 return 'e-accordion';
60 }
61
62 public function get_title() {
63 return esc_html__( 'Accordion', 'elementor' );
64 }
65
66 public function get_keywords() {
67 return [ 'ato', 'atom', 'atoms', 'atomic', 'accordion', 'faq', 'collapse' ];
68 }
69
70 public function get_icon() {
71 return 'eicon-accordion';
72 }
73
74 public static function get_computed_html_tag( array $settings ): string {
75 return Html_Tag_Computer::compute( $settings, 'div' );
76 }
77
78 protected static function define_props_schema(): array {
79 return [
80 'classes' => Classes_Prop_Type::make()
81 ->default( [] ),
82 'attributes' => Attributes_Prop_Type::make()->meta( Overridable_Prop_Type::ignore() ),
83 'default_state' => String_Prop_Type::make()
84 ->enum( [ 'first_expanded', 'all_collapsed' ] )
85 ->default( 'first_expanded' )
86 ->description( 'Which items are open when the accordion first renders. Valid values: first_expanded, all_collapsed' ),
87 'max_expanded' => String_Prop_Type::make()
88 ->enum( [ 'one', 'multiple' ] )
89 ->default( 'one' )
90 ->description( 'How many items can be open at the same time. Valid values: one, multiple' ),
91 // Single, global, user-facing toggle - there is no per-item Show Icon (confirmed with the
92 // PM: users want icons on every item or on none, never mixed). It is exposable as a
93 // component property, so no `Overridable_Prop_Type::ignore()`. Every `e-accordion-item-header`
94 // carries its own mirrored `show_icon` prop (see that class) purely because the children-
95 // dependencies reconciler evaluates a rule against the *declaring* element's own settings
96 // and can only attach/detach that element's *direct* children - a root-level prop here can
97 // never drive a grandchild's presence. Do not "clean up" the mirror; the duplication is
98 // structural, not an oversight.
99 'show_icon' => Boolean_Prop_Type::make()->default( true )
100 ->description( 'Whether every item header shows an open/closed indicator icon. Applies to all items; there is no per-item override.' ),
101 'faq_schema' => Boolean_Prop_Type::make()->default( false )
102 ->description( 'Whether to output an FAQPage JSON-LD structured data script on the frontend, built from each item\'s title (question) and content (answer).' ),
103 ];
104 }
105
106 protected function define_atomic_controls(): array {
107 return [
108 Section::make()
109 ->set_label( __( 'Content', 'elementor' ) )
110 ->set_id( 'content' )
111 ->set_items( [
112 Accordion_Items_Control::make()
113 ->set_label( __( 'Accordion Items', 'elementor' ) )
114 ->set_meta( [
115 'layout' => 'custom',
116 ] ),
117 Switch_Control::bind_to( 'show_icon' )
118 ->set_label( esc_html__( 'Show Icon', 'elementor' ) ),
119 ] ),
120 Section::make()
121 ->set_label( __( 'Settings', 'elementor' ) )
122 ->set_id( 'settings' )
123 ->set_items( [
124 Toggle_Control::bind_to( 'default_state' )
125 ->set_label( esc_html__( 'Default State', 'elementor' ) )
126 ->add_options( [
127 'first_expanded' => [ 'title' => esc_html__( 'First expanded', 'elementor' ) ],
128 'all_collapsed' => [ 'title' => esc_html__( 'All collapsed', 'elementor' ) ],
129 ] )
130 ->set_exclusive( true )
131 ->set_convert_options( true )
132 ->set_size( 'tiny' )
133 ->set_full_width( true ),
134 Toggle_Control::bind_to( 'max_expanded' )
135 ->set_label( esc_html__( 'Max Items Expanded', 'elementor' ) )
136 ->add_options( [
137 'one' => [ 'title' => esc_html__( 'One', 'elementor' ) ],
138 'multiple' => [ 'title' => esc_html__( 'Multiple', 'elementor' ) ],
139 ] )
140 ->set_exclusive( true )
141 ->set_convert_options( true )
142 ->set_size( 'tiny' )
143 ->set_full_width( true ),
144 Switch_Control::bind_to( 'faq_schema' )
145 ->set_label( esc_html__( 'FAQ Schema', 'elementor' ) ),
146 Text_Control::bind_to( '_cssid' )
147 ->set_label( __( 'ID', 'elementor' ) )
148 ->set_meta( $this->get_css_id_control_meta() ),
149 ] ),
150 ];
151 }
152
153 protected function define_base_styles(): array {
154 return [
155 static::BASE_STYLE_KEY => Style_Definition::make()
156 ->add_variant(
157 Style_Variant::make()
158 ->add_props( [
159 'display' => String_Prop_Type::generate( 'flex' ),
160 'flex-direction' => String_Prop_Type::generate( 'column' ),
161 // Neutralises the 10px inline padding that `.e-con` (added to every atomic
162 // element by `render_base_classes`) resolves from the container defaults.
163 // The item's own 10px lives on the header and the content slot, so any
164 // padding here or on the item would just inset the whole accordion.
165 'padding' => Size_Prop_Type::generate( [
166 'size' => 0,
167 'unit' => 'px',
168 ] ),
169 ] )
170 ),
171 ];
172 }
173
174 protected function define_allowed_child_types() {
175 return [ self::ELEMENT_TYPE_ITEM ];
176 }
177
178 protected function define_default_children() {
179 $items = [];
180
181 foreach ( range( 1, self::DEFAULT_ITEM_COUNT ) as $i ) {
182 $items[] = $this->build_default_item( $i );
183 }
184
185 return $items;
186 }
187
188 /**
189 * Builds one `e-accordion-item` with its numbered title seeded explicitly.
190 *
191 * Each level of `default_children` is hydrated independently client-side
192 * (`Atomic_Element_Base_Model::onElementCreate()`, driven by `hydrateDefaultChildren: true`),
193 * so the title slot's own `define_default_children()` has no way to know which item it
194 * belongs to. Building the item → header → title → paragraph chain explicitly here lets the
195 * numbered text ("Accordion Item 1", "Accordion Item 2") reach the rendered paragraph. The
196 * icon and content branches don't need per-index content, so they keep using their own
197 * `define_default_children()` via `hydrate_default_children( true )`.
198 *
199 * @param int $index
200 * @return array
201 */
202 private function build_default_item( int $index ): array {
203 /* translators: %d: Accordion item position. */
204 $numbered_title = sprintf( esc_html__( 'Accordion Item %d', 'elementor' ), $index );
205
206 $title = Element_Builder::make( self::ELEMENT_TYPE_TITLE )
207 ->editor_settings( [
208 'title' => esc_html__( 'Title', 'elementor' ),
209 ] )
210 ->children( [
211 Atomic_Paragraph::generate()
212 ->settings( [
213 'paragraph' => Escaped_Html_Prop_Type::generate( $numbered_title ),
214 'tag' => String_Prop_Type::generate( 'span' ),
215 ] )
216 ->build(),
217 ] )
218 ->build();
219
220 $icon = Element_Builder::make( self::ELEMENT_TYPE_ICON )
221 ->hydrate_default_children( true )
222 ->editor_settings( [
223 'title' => esc_html__( 'Icon', 'elementor' ),
224 ] )
225 ->build();
226
227 $header = Element_Builder::make( self::ELEMENT_TYPE_HEADER )
228 ->editor_settings( [
229 'title' => esc_html__( 'Header', 'elementor' ),
230 ] )
231 // Seeded explicitly, mirroring the TS repeater's `buildItemModel()`
232 // (`accordion-items-control/use-actions.ts`) - both sides of the item-building split need
233 // the header's mirrored `show_icon` prop set on creation, not left to its own schema default,
234 // or the two default items this method builds and any item added later through the panel
235 // would start from different `show_icon` states (set vs. unset). See the comment on the
236 // mirrored prop in `Atomic_Accordion_Item_Header` for why the duplication exists at all.
237 //
238 // Reads the *schema's* default here, not `$this->get_atomic_setting( 'show_icon' )`: this
239 // runs while `define_default_children()` is itself building the initial config, before
240 // `ensure_settings()` has finished resolving this element's own settings, so calling
241 // `get_atomic_setting()` here recurses back into the same in-progress settings resolution
242 // and fatals. There is also no "current" root value to seed from yet - the two default
243 // items always start from the schema default, the same as the root's own `show_icon`
244 // control does the first time it renders.
245 ->settings( [
246 'show_icon' => self::define_props_schema()['show_icon']->get_default(),
247 ] )
248 ->children( [ $title, $icon ] )
249 ->build();
250
251 $content = Element_Builder::make( self::ELEMENT_TYPE_CONTENT )
252 ->hydrate_default_children( true )
253 ->editor_settings( [
254 'title' => esc_html__( 'Content', 'elementor' ),
255 ] )
256 ->build();
257
258 return Element_Builder::make( self::ELEMENT_TYPE_ITEM )
259 ->editor_settings( [
260 'title' => $numbered_title,
261 'initial_position' => $index,
262 ] )
263 ->children( [ $header, $content ] )
264 ->build();
265 }
266
267 /**
268 * Index of a direct `e-accordion-item` child, by element id.
269 *
270 * Items read this through the render context to decide which one carries the `open`
271 * attribute. Mirrors `Atomic_Tabs::get_tab_index()`.
272 *
273 * @param string $item_id
274 * @return int|null
275 */
276 private function get_item_index( string $item_id ) {
277 $item_ids = Collection::make( $this->get_children() )
278 ->filter( fn( $child ) => $child->get_type() === self::ELEMENT_TYPE_ITEM )
279 ->map( fn( $child ) => $child->get_id() )
280 ->flip()
281 ->all();
282
283 return $item_ids[ $item_id ] ?? null;
284 }
285
286 /**
287 * Root markdown output: one `### <title>` heading followed by the content subtree's markdown,
288 * per accordion item, blank-line separated.
289 *
290 * `Element_Base::render_markdown()` (the inherited default - fact 6) already recurses
291 * `get_children()` and joins every non-empty result with a blank line, with no regard for
292 * structure. Left unchanged, item -> header -> title -> paragraph and item -> content -> paragraph
293 * would all flatten into one undifferentiated string per item ("Title\n\nContent"), and there is
294 * no delimiter left in that output to tell where the title ends and the content begins - the
295 * heading marker has to be added *before* the two are joined, not recovered afterwards. That
296 * ruled out simply wrapping `parent::render_markdown()` and reformatting its return value.
297 *
298 * Instead this walks the `e-accordion-item` children directly - the same
299 * `Collection::make($this->get_children())->filter()` idiom `get_item_index()` and
300 * `build_faq_schema_json()` already use in this file - keeping each item's title and content
301 * markdown separate until the `### ` heading is applied. The actual text/markdown extraction
302 * still comes from calling `render_markdown()` on the title and content sub-elements (per the
303 * plan's reuse-over-reimplementation preference, the same way `get_faq_item_text()` does above);
304 * only the top-level assembly differs from the inherited default.
305 *
306 * @return string
307 */
308 public function render_markdown(): string {
309 $items = Collection::make( $this->get_children() )
310 ->filter( fn( $child ) => $child->get_type() === self::ELEMENT_TYPE_ITEM );
311
312 $blocks = [];
313
314 foreach ( $items as $item ) {
315 $title_markdown = trim( $this->get_item_title_markdown( $item ) );
316 $content_markdown = trim( $this->get_item_content_markdown( $item ) );
317
318 if ( '' === $title_markdown && '' === $content_markdown ) {
319 continue;
320 }
321
322 $blocks[] = '### ' . $title_markdown . "\n\n" . $content_markdown;
323 }
324
325 return implode( "\n\n", $blocks );
326 }
327
328 /**
329 * Markdown of an item's title, via `e-accordion-item` -> `e-accordion-item-header` ->
330 * `e-accordion-item-title` -> `render_markdown()`.
331 *
332 * @param Atomic_Element_Base $item
333 * @return string
334 */
335 private function get_item_title_markdown( $item ): string {
336 $header = Collection::make( $item->get_children() )
337 ->filter( fn( $child ) => $child->get_type() === self::ELEMENT_TYPE_HEADER )
338 ->first();
339
340 if ( null === $header ) {
341 return '';
342 }
343
344 $title = Collection::make( $header->get_children() )
345 ->filter( fn( $child ) => $child->get_type() === self::ELEMENT_TYPE_TITLE )
346 ->first();
347
348 return null === $title ? '' : $title->render_markdown();
349 }
350
351 /**
352 * Markdown of an item's content, via `e-accordion-item` -> `e-accordion-item-content` ->
353 * `render_markdown()`.
354 *
355 * @param Atomic_Element_Base $item
356 * @return string
357 */
358 private function get_item_content_markdown( $item ): string {
359 $content = Collection::make( $item->get_children() )
360 ->filter( fn( $child ) => $child->get_type() === self::ELEMENT_TYPE_CONTENT )
361 ->first();
362
363 return null === $content ? '' : $content->render_markdown();
364 }
365
366 protected function get_templates(): array {
367 return [
368 'elementor/elements/atomic-accordion' => __DIR__ . '/atomic-accordion.html.twig',
369 ];
370 }
371
372 /**
373 * Editor-only: auto-opens the item containing the descendant the editor just selected
374 * (`handlers/editor-accordion-state.js`). Nothing is added on the frontend - the toggle itself
375 * stays native, no JS involved.
376 *
377 * @return array
378 */
379 public function get_script_depends() {
380 $global_depends = parent::get_script_depends();
381
382 if ( Plugin::$instance->preview->is_preview_mode() ) {
383 return array_merge( $global_depends, [ 'elementor-accordion-preview-handler' ] );
384 }
385
386 return $global_depends;
387 }
388
389 public function register_frontend_handlers() {
390 $assets_url = ELEMENTOR_ASSETS_URL;
391 $min_suffix = ( Utils::is_script_debug() || Utils::is_elementor_tests() ) ? '' : '.min';
392
393 wp_register_script(
394 'elementor-accordion-preview-handler',
395 "{$assets_url}js/accordion-preview-handler{$min_suffix}.js",
396 [ Frontend_Assets_Loader::FRONTEND_HANDLERS_HANDLE ],
397 ELEMENTOR_VERSION,
398 true
399 );
400 }
401
402 /**
403 * Adds the FAQPage JSON-LD payload to the template context, when enabled.
404 *
405 * Kept out of the editor preview: structured data is a frontend/SEO concern, and building it
406 * from live descendant markdown on every preview re-render would be wasted work with no
407 * user-visible effect (the `<script type="application/ld+json">` tag renders invisibly).
408 *
409 * @return array
410 */
411 protected function build_template_context(): array {
412 $faq_schema_json = null;
413
414 if ( $this->get_atomic_setting( 'faq_schema' ) && ! Plugin::$instance->preview->is_preview_mode() ) {
415 $faq_schema_json = $this->build_faq_schema_json();
416 }
417
418 return array_merge( $this->build_base_template_context(), [
419 'faq_schema_json' => $faq_schema_json,
420 ] );
421 }
422
423 /**
424 * Builds the `wp_json_encode()`-ready FAQPage JSON-LD string from the accordion's items, or
425 * `null` when there is nothing valid to emit.
426 *
427 * @return string|null
428 */
429 private function build_faq_schema_json() {
430 $entities = [];
431
432 $items = Collection::make( $this->get_children() )
433 ->filter( fn( $child ) => $child->get_type() === self::ELEMENT_TYPE_ITEM );
434
435 foreach ( $items as $item ) {
436 $question = $this->get_faq_item_text( $item, self::ELEMENT_TYPE_HEADER, self::ELEMENT_TYPE_TITLE );
437 $answer = $this->get_faq_item_text( $item, null, self::ELEMENT_TYPE_CONTENT );
438
439 if ( '' === $question || '' === $answer ) {
440 continue;
441 }
442
443 $entities[] = [
444 '@type' => 'Question',
445 'name' => $question,
446 'acceptedAnswer' => [
447 '@type' => 'Answer',
448 'text' => $answer,
449 ],
450 ];
451 }
452
453 if ( empty( $entities ) ) {
454 return null;
455 }
456
457 return wp_json_encode( [
458 '@context' => 'https://schema.org',
459 '@type' => 'FAQPage',
460 'mainEntity' => $entities,
461 ] );
462 }
463
464 /**
465 * Plain text of a sub-element within an `e-accordion-item`, found by element type.
466 *
467 * When `$via_type` is given, the target is looked up one level deeper (e.g. the title lives
468 * inside the header, not directly on the item). `render_markdown()` is `Element_Base`'s existing
469 * recursive default (fact 6) — reused as-is, not re-implemented. Its output is markdown syntax
470 * (`**bold**`, `[text](url)`, …), not HTML, so `strip_markdown_syntax()` — not
471 * `wp_strip_all_tags()` — does the real cleanup; `wp_strip_all_tags()` stays only as a
472 * defensive second pass in case some element's `render_markdown()` ever emits raw HTML.
473 *
474 * @param Atomic_Element_Base $item
475 * @param string|null $via_type
476 * @param string $target_type
477 * @return string
478 */
479 private function get_faq_item_text( $item, $via_type, string $target_type ): string {
480 $scope = $item;
481
482 if ( null !== $via_type ) {
483 $scope = Collection::make( $item->get_children() )
484 ->filter( fn( $child ) => $child->get_type() === $via_type )
485 ->first();
486
487 if ( null === $scope ) {
488 return '';
489 }
490 }
491
492 $target = Collection::make( $scope->get_children() )
493 ->filter( fn( $child ) => $child->get_type() === $target_type )
494 ->first();
495
496 if ( null === $target ) {
497 return '';
498 }
499
500 $plain = $this->strip_markdown_syntax( $target->render_markdown() );
501
502 return trim( wp_strip_all_tags( $plain ) );
503 }
504
505 /**
506 * Strips the markdown syntax `render_markdown()` produces via `Html_To_Markdown::convert()`
507 * (see `modules/markdown-render/html-to-markdown.php`) so it never leaks into JSON-LD text
508 * fields verbatim — `**bold**`, `[text](url)`, `# heading`, etc. are not valid prose, and a
509 * FAQPage consumer (Google, any schema.org parser) should see clean text. `wp_strip_all_tags()`
510 * cannot do this job: it strips HTML tags, and markdown syntax is punctuation, not HTML.
511 *
512 * Deliberately scoped to exactly what `Html_To_Markdown::convert()` can emit in this codebase's
513 * configuration, not a general-purpose markdown parser:
514 * - fenced ``` code blocks and inline `code` spans (backtick fence length varies)
515 * - `![alt](src)` images and `[text](url)` links — the visible text is kept, the target dropped
516 * - `**bold**` / `__bold__`, `*italic*` / `_italic_`, `~~strikethrough~~`
517 * - `#` … `######` heading markers and `-`/`*`/`+`/`1.` list markers at line start
518 * - `> ` blockquote markers (repeated per nesting depth) at line start — reachable from a plain
519 * `Atomic_Paragraph`, which whitelists `<blockquote>` in its own output filter and feeds it
520 * straight into `Html_To_Markdown::convert()`, so this is not a hypothetical case
521 * - the backslash-escaping `Html_To_Markdown::escape_text()` applies to literal
522 * `\ \` * _ [ ]` characters in ordinary text, so those round-trip back to plain characters
523 * instead of surfacing a stray backslash
524 *
525 * The formatting-delimiter regexes below (code/bold/italic/link/image) all require an
526 * *unescaped* delimiter via `(?<!\\)`: `escape_text()` backslash-escapes a literal `* _ \` [ ]`
527 * character wherever it appears in plain text (not just when paired), so a string like
528 * `1* free*` (literal asterisks, no formatting intended) round-trips through `render_markdown()`
529 * as `1\* free\*`. Without the guard, the two escaped asterisks look like a real `*...*` pair to
530 * a naive regex, which eats the enclosed text and leaves orphaned backslashes behind — this was
531 * exactly the Round 1→2 regression. Unescaping runs *after* the delimiter regexes so the escaped
532 * characters are still backslash-prefixed (and therefore excluded by the lookbehind) while those
533 * regexes run.
534 *
535 * Tables aren't handled: no reachable path in this codebase currently produces table markdown
536 * for an accordion title/content subtree, and adding unused coverage would be guessing at
537 * requirements rather than matching them.
538 *
539 * @param string $markdown
540 * @return string
541 */
542 private function strip_markdown_syntax( string $markdown ): string {
543 $text = $markdown;
544
545 // Fenced code blocks: keep only the code content.
546 $text = preg_replace( '/```[^\n`]*\n(.*?)\n```/s', '$1', $text );
547
548 // Inline code spans (only when both fences are unescaped — see docblock).
549 $text = preg_replace( '/(?<!\\\\)`+(.*?)(?<!\\\\)`+/s', '$1', $text );
550
551 // Images and links: keep the visible text, drop the target (unescaped opening bracket only).
552 $text = preg_replace( '/(?<!\\\\)!\[([^\]]*)\]\([^)]*\)/', '$1', $text );
553 $text = preg_replace( '/(?<!\\\\)\[([^\]]*)\]\([^)]*\)/', '$1', $text );
554
555 // Headings: leading #'s at the start of a line.
556 $text = preg_replace( '/^#{1,6}[ \t]+/m', '', $text );
557
558 // List markers: "- ", "* ", "+ " or "1. " at a (possibly indented) line start.
559 $text = preg_replace( '/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/m', '', $text );
560
561 // Blockquote markers: "> " (repeated for nested quotes) at the start of a line.
562 $text = preg_replace( '/^(?:>[ \t]?)+/m', '', $text );
563
564 // Bold, italic (unescaped delimiters only — see docblock), strikethrough (never escaped).
565 $text = preg_replace( '/(?<!\\\\)(\*\*|__)(.+?)(?<!\\\\)\1/s', '$2', $text );
566 $text = preg_replace( '/(?<!\\\\)\*(.+?)(?<!\\\\)\*/s', '$1', $text );
567 $text = preg_replace( '/(?<!\\\\)_(.+?)(?<!\\\\)_/s', '$1', $text );
568 $text = preg_replace( '/~~(.+?)~~/s', '$1', $text );
569
570 // Undo the converter's backslash-escaping of literal markdown-special characters.
571 $text = preg_replace( '/\\\\([\\\\`*_\[\]])/', '$1', $text );
572
573 // Collapse whitespace left behind by the removals above.
574 $text = preg_replace( '/[ \t]+/', ' ', $text );
575 $text = preg_replace( '/\s*\n\s*/', ' ', $text );
576
577 return trim( $text );
578 }
579
580 /**
581 * Exposes the accordion's identity and per-item lookup to descendants that render inside its
582 * pass (item, header, title, icon, content) via `Render_Context::get( self::class )`.
583 *
584 * @return array
585 */
586 protected function define_render_context(): array {
587 return [
588 [
589 'context' => [
590 'accordion-id' => $this->get_id(),
591 'get-item-index' => fn( $item_id ) => $this->get_item_index( $item_id ),
592 'default-state' => $this->get_atomic_setting( 'default_state' ),
593 'max-expanded' => $this->get_atomic_setting( 'max_expanded' ),
594 ],
595 ],
596 ];
597 }
598 }
599