PluginProbe ʕ •ᴥ•ʔ
Kirki – Freeform Page Builder, Website Builder & Customizer / 6.2.0
Kirki – Freeform Page Builder, Website Builder & Customizer v6.2.0
6.2.0 6.1.1 6.1.0 6.0.14 6.0.13 6.0.12 6.0.11 6.0.10 6.0.9 6.0.8 6.0.7 6.0.6 6.0.5 6.0.4 6.0.3 6.0.2 6.0.1 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 4.0.19 4.0.20 4.0.21 4.0.22 4.0.23 4.0.24 4.1 4.2.0 5.0.0 5.1.0 5.1.1 5.2.0 5.2.1 5.2.2 5.2.3 6.0.0 trunk 3.0.40 3.0.41 3.0.42 3.0.43 3.0.44 3.0.45 3.1.0 3.1.1 3.1.2
kirki / app / Services / ContentManagerTemplateBinder.php
kirki / app / Services Last commit date
AppsService.php 2 weeks ago CollaborationCommentService.php 1 month ago CollaborationService.php 5 days ago CollectionItemService.php 1 month ago CollectionService.php 5 days ago ContentManagerTemplateBinder.php 5 days ago ContentManagerTemplateService.php 5 days ago EditorService.php 1 month ago FontService.php 2 weeks ago FormSubmissionService.php 5 days ago GlobalDataService.php 2 weeks ago MediaService.php 1 month ago PageService.php 5 days ago PageSettingsService.php 1 month ago PostService.php 5 days ago UtilityPageService.php 5 days ago
ContentManagerTemplateBinder.php
425 lines
1 <?php
2
3 namespace Kirki\App\Services;
4
5 defined('ABSPATH') || exit;
6
7 use Exception;
8 use Kirki\App\Constants\Collection\CustomFieldTypes;
9 use Kirki\App\Models\Collection;
10 use Kirki\App\Supports\ContentManager;
11
12 /**
13 * Binds collection metadata to an existing normalized Kirki template tree.
14 *
15 * This class deliberately has no knowledge of element defaults. The template
16 * assets own structure and styling; the binder only resolves placeholders,
17 * repeats field-backed prototypes and removes unavailable optional slots.
18 */
19 class ContentManagerTemplateBinder
20 {
21 /** @var array */
22 protected $fields = [];
23
24 /** @var array */
25 protected $fields_by_key = [];
26
27 /** @var array */
28 protected $fields_by_id = [];
29
30 /** @var string */
31 protected $preset_type = 'generic';
32
33 /** @var string */
34 protected $page_kind = '';
35
36 /**
37 * Bind a normalized template to a collection.
38 *
39 * @param array $template Template blocks/styles.
40 * @param Collection $collection Collection with fields loaded.
41 * @param array $context Preset and page-kind context.
42 * @return array
43 * @throws Exception When a required template binding cannot be resolved.
44 */
45 public function bind(array $template, Collection $collection, array $context = [])
46 {
47 $this->fields = is_array($collection->fields->meta_value ?? null)
48 ? $collection->fields->meta_value
49 : [];
50 $this->fields_by_key = [];
51 $this->fields_by_id = [];
52 foreach ($this->fields as $field) {
53 $field_keys = [
54 $this->normalize_key((string) ($field['templateKey'] ?? '')),
55 $this->normalize_key((string) ($field['title'] ?? '')),
56 ];
57 foreach (array_unique(array_filter($field_keys)) as $field_key) {
58 $this->fields_by_key[$field_key] = $field;
59 }
60 if (!empty($field['id'])) {
61 $this->fields_by_id[(string) $field['id']] = $field;
62 }
63 }
64 $this->preset_type = (string) ($context['preset_type'] ?? $this->resolve_preset_type($collection));
65 $this->page_kind = in_array($context['page_kind'] ?? '', ['index', 'details'], true)
66 ? (string) $context['page_kind']
67 : '';
68
69 $blocks = $template['blocks'] ?? [];
70 $styles = $template['styles'] ?? [];
71 $remove_ids = [];
72
73 foreach ($blocks as $id => &$block) {
74 if (!isset($block['properties']['dynamicContent'])) {
75 continue;
76 }
77 $dynamic = &$block['properties']['dynamicContent'];
78 if (!is_array($dynamic)) {
79 continue;
80 }
81
82 if ($this->is_collection_binding($block, $dynamic)) {
83 $dynamic['type'] = ContentManager::get_child_post_post_type_value($collection->ID);
84 continue;
85 }
86
87 $value = $dynamic['value'] ?? '';
88 if ($value === '__CM_ROLE:index-media__') {
89 $field = $this->find_index_media_field($this->preset_type);
90 $field ? $this->bind_field($block, $field) : $remove_ids[] = $id;
91 } elseif ($value === '__CM_ROLE:details-media__') {
92 $field = $this->find_details_media_field($this->preset_type);
93 $field ? $this->bind_field($block, $field) : $remove_ids[] = $id;
94 } elseif (preg_match('/^__CM_FIELD:([a-z0-9-]+)__$/', $value, $matches)) {
95 $field = $this->fields_by_key[$matches[1]] ?? null;
96 $field ? $this->bind_field($block, $field) : $remove_ids[] = $id;
97 } elseif ($this->is_exported_content_manager_binding($dynamic)) {
98 $field = $this->resolve_exported_field($block, $dynamic);
99 $field ? $this->bind_field($block, $field) : $remove_ids[] = $id;
100 }
101 }
102 unset($block, $dynamic);
103
104 $this->expand_rich_text_repeat($blocks, $styles);
105 foreach (array_unique($remove_ids) as $id) {
106 $this->remove_subtree($blocks, $styles, $id);
107 }
108 $this->prune_empty_wrappers($blocks, $styles);
109
110 $template['blocks'] = $blocks;
111 $template['styles'] = $styles;
112 $this->assert_resolved($template);
113
114 return $template;
115 }
116
117 /**
118 * Determine whether a collection element points at a source collection.
119 *
120 * Local templates use a placeholder; raw Builder exports contain the
121 * source collection's concrete kirki_cm_* post type.
122 */
123 protected function is_collection_binding(array $block, array $dynamic)
124 {
125 if (($dynamic['type'] ?? '') === '__CM_COLLECTION_POST_TYPE__') {
126 return true;
127 }
128
129 $type = (string) ($dynamic['type'] ?? '');
130 return in_array($block['name'] ?? '', ['collection', 'slider'], true)
131 && ($dynamic['collectionType'] ?? '') === 'posts'
132 && strpos($type, KIRKI_CONTENT_MANAGER_PREFIX . '_') === 0;
133 }
134
135 /**
136 * Identify a concrete Content Manager binding from an exported page.
137 */
138 protected function is_exported_content_manager_binding(array $dynamic)
139 {
140 $value = (string) ($dynamic['value'] ?? '');
141
142 return ($dynamic['type'] ?? '') === 'post'
143 && $value !== ''
144 && strpos($value, '__CM_') !== 0
145 && strpos($value, 'post_') !== 0;
146 }
147
148 /**
149 * Match a source export binding to a field in the target collection.
150 *
151 * Explicit stable keys win. Legacy assets fall back to media roles,
152 * semantic layer titles and, finally, an existing target field ID.
153 */
154 protected function resolve_exported_field(array $block, array $dynamic)
155 {
156 $value = (string) ($dynamic['value'] ?? '');
157 $field_key = $this->normalize_key(
158 (string) ($block['properties']['customAttributes']['data-kirki-cm-field'] ?? '')
159 );
160
161 if ($field_key !== '') {
162 return $this->fields_by_key[$field_key] ?? null;
163 }
164
165 if (($block['name'] ?? '') === 'image') {
166 if ($this->page_kind === 'index') {
167 return $this->find_index_media_field($this->preset_type);
168 }
169 if ($this->page_kind === 'details') {
170 return $this->find_details_media_field($this->preset_type);
171 }
172 }
173
174 $keys = [
175 $this->normalize_key((string) ($block['customTitle'] ?? '')),
176 $this->normalize_key((string) ($block['title'] ?? '')),
177 ];
178 $contents = $block['properties']['contents'][0] ?? '';
179 if (is_string($contents)) {
180 $keys[] = $this->normalize_key($contents);
181 }
182
183 foreach (array_unique(array_filter($keys)) as $key) {
184 if (isset($this->fields_by_key[$key])) {
185 return $this->fields_by_key[$key];
186 }
187 }
188
189 if (isset($this->fields_by_id[$value])) {
190 return $this->fields_by_id[$value];
191 }
192
193 return null;
194 }
195
196 /**
197 * Resolve stored preset metadata or recognize an unmodified legacy preset.
198 */
199 public function resolve_preset_type(Collection $collection)
200 {
201 if ($collection->preset_type && !empty($collection->preset_type->meta_value)) {
202 return (string) $collection->preset_type->meta_value;
203 }
204
205 $fields = is_array($collection->fields->meta_value ?? null) ? $collection->fields->meta_value : [];
206 $field_keys = array_map(function ($field) {
207 return $field['templateKey'] ?? $this->normalize_key($field['title'] ?? '');
208 }, $fields);
209 $signatures = [
210 'team-members' => ['email', 'job-title', 'bio', 'phone', 'image', 'website', 'team', 'linkedin', 'facebook'],
211 'portfolio' => ['title', 'client-name', 'description', 'image', 'year', 'type', 'link', 'github', 'featured'],
212 'projects' => ['title', 'client-name', 'client-logo', 'description', 'image', 'year', 'service', 'link', 'featured'],
213 'recipes' => ['title', 'ingredients', 'cooking-instruction', 'image', 'preparation-time', 'thumbnail-image', 'featured'],
214 'jobs' => ['title', 'description', 'location', 'salary', 'work-hours', 'job-types', 'requirements', 'opening', 'application-deadline'],
215 'clients' => ['client-name', 'product-name', 'email', 'phone', 'purchase-date-product-delivered', 'image', 'product-image-logo'],
216 'listings' => ['listing-type', 'rent-or-sale-price', 'description', 'no-of-rooms', 'no-of-baths', 'square-feet', 'availability', 'property-image', 'listing-created-date', 'address', 'agent-contact-info'],
217 ];
218
219 foreach ($signatures as $preset_type => $required_keys) {
220 if (empty(array_diff($required_keys, $field_keys))) {
221 return $preset_type;
222 }
223 }
224
225 return 'generic';
226 }
227
228 /** Replace one field placeholder with the collection's persisted field ID. */
229 protected function bind_field(array &$block, array $field)
230 {
231 $block['title'] = $field['title'] ?? $block['title'];
232 $field_key = $this->normalize_key(
233 (string) ($field['templateKey'] ?? $field['title'] ?? '')
234 );
235 $dynamic_content = is_array($block['properties']['dynamicContent'] ?? null)
236 ? $block['properties']['dynamicContent']
237 : [];
238 $block['properties']['dynamicContent'] = array_merge($dynamic_content, [
239 'type' => 'post',
240 'value' => $field['id'],
241 ]);
242 if ($field_key !== '') {
243 $custom_attributes = is_array($block['properties']['customAttributes'] ?? null)
244 ? $block['properties']['customAttributes']
245 : [];
246 $custom_attributes['data-kirki-cm-field'] = $field_key;
247 $block['properties']['customAttributes'] = $custom_attributes;
248 }
249 if (($block['name'] ?? '') === 'image') {
250 $block['properties']['attributes']['alt'] = $field['title'] ?? 'Image';
251 $block['properties']['attributes']['name'] = $field['title'] ?? 'Image';
252 }
253 }
254
255 /** Clone the normalized rich-text prototype once per rich-text field. */
256 protected function expand_rich_text_repeat(array &$blocks, array &$styles)
257 {
258 $prototype_id = null;
259 foreach ($blocks as $id => $block) {
260 if (($block['properties']['dynamicContent']['value'] ?? '') === '__CM_REPEAT:rich-text__') {
261 $prototype_id = $id;
262 break;
263 }
264 }
265 if ($prototype_id === null) {
266 return;
267 }
268
269 $prototype = $blocks[$prototype_id];
270 $parent_id = $prototype['parentId'];
271 $replacement_ids = [];
272 $repeat_id_prefix = preg_replace('/Prototype$/', 'Field', $prototype_id);
273 $rich_text_fields = array_values(array_filter($this->fields, function ($field) {
274 return ($field['type'] ?? '') === CustomFieldTypes::RICH_TEXT;
275 }));
276
277 foreach ($rich_text_fields as $index => $field) {
278 $block = $prototype;
279 $block_id = $repeat_id_prefix . ($index + 1);
280 $block['id'] = $block_id;
281 $block['title'] = $field['title'] ?? 'Rich Text';
282 $block['properties']['contents'] = [$block['title']];
283 $this->bind_field($block, $field);
284 $block['styleIds'] = $this->clone_styles($prototype['styleIds'] ?? [], $styles, $block_id);
285 $blocks[$block_id] = $block;
286 $replacement_ids[] = $block_id;
287 }
288
289 $children = $blocks[$parent_id]['children'] ?? [];
290 $position = array_search($prototype_id, $children, true);
291 if ($position !== false) {
292 array_splice($children, $position, 1, $replacement_ids);
293 $blocks[$parent_id]['children'] = $children;
294 }
295 $this->remove_subtree($blocks, $styles, $prototype_id, false);
296 }
297
298 /** Clone style records so repeated blocks remain independently editable. */
299 protected function clone_styles(array $style_ids, array &$styles, string $seed)
300 {
301 $clones = [];
302 foreach ($style_ids as $index => $style_id) {
303 if (!isset($styles[$style_id])) {
304 continue;
305 }
306 $new_id = $seed . 'Style' . ($index === 0 ? '' : $index + 1);
307 $style = $styles[$style_id];
308 $style['id'] = $new_id;
309 $style['name'] = strtolower((string) preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $new_id));
310 $styles[$new_id] = $style;
311 $clones[] = $new_id;
312 }
313
314 return $clones;
315 }
316
317 /** Remove a block, all descendants and their private style records. */
318 protected function remove_subtree(array &$blocks, array &$styles, string $id, bool $detach = true)
319 {
320 if (!isset($blocks[$id])) {
321 return;
322 }
323 foreach ($blocks[$id]['children'] ?? [] as $child_id) {
324 $this->remove_subtree($blocks, $styles, $child_id, false);
325 }
326 if ($detach) {
327 $parent_id = $blocks[$id]['parentId'] ?? null;
328 if ($parent_id && isset($blocks[$parent_id]['children'])) {
329 $blocks[$parent_id]['children'] = array_values(array_diff($blocks[$parent_id]['children'], [$id]));
330 }
331 }
332 foreach ($blocks[$id]['styleIds'] ?? [] as $style_id) {
333 $is_used_elsewhere = false;
334 foreach ($blocks as $block_id => $block) {
335 if ($block_id !== $id && in_array($style_id, $block['styleIds'] ?? [], true)) {
336 $is_used_elsewhere = true;
337 break;
338 }
339 }
340 if (!$is_used_elsewhere) {
341 unset($styles[$style_id]);
342 }
343 }
344 unset($blocks[$id]);
345 }
346
347 /** Remove optional div wrappers left empty after unavailable fields. */
348 protected function prune_empty_wrappers(array &$blocks, array &$styles)
349 {
350 do {
351 $removed = false;
352 foreach ($blocks as $id => $block) {
353 if (($block['name'] ?? '') === 'div' && isset($block['children']) && empty($block['children'])) {
354 $this->remove_subtree($blocks, $styles, $id);
355 $removed = true;
356 break;
357 }
358 }
359 } while ($removed);
360 }
361
362 protected function find_index_media_field(string $preset_type)
363 {
364 if ($preset_type === 'recipes') {
365 if (isset($this->fields_by_key['image'])) {
366 return $this->fields_by_key['image'];
367 }
368 if (isset($this->fields_by_key['thumbnail-image'])) {
369 return $this->fields_by_key['thumbnail-image'];
370 }
371 }
372 return $this->find_field(['image', 'profile-image', 'property-image', 'product-image-logo', 'client-logo'], [CustomFieldTypes::IMAGE]);
373 }
374
375 protected function find_details_media_field(string $preset_type)
376 {
377 if ($preset_type === 'recipes' && isset($this->fields_by_key['image'])) {
378 return $this->fields_by_key['image'];
379 }
380 return $this->find_field(['image', 'property-image', 'product-image-logo', 'client-logo'], [CustomFieldTypes::IMAGE]);
381 }
382
383 protected function find_field(array $keys, array $types)
384 {
385 foreach ($keys as $key) {
386 if (isset($this->fields_by_key[$key]) && in_array($this->fields_by_key[$key]['type'] ?? '', $types, true)) {
387 return $this->fields_by_key[$key];
388 }
389 }
390 foreach ($this->fields as $field) {
391 if (in_array($field['type'] ?? '', $types, true)) {
392 return $field;
393 }
394 }
395 return null;
396 }
397
398 public function normalize_key(string $value)
399 {
400 $value = strtolower($value);
401 $value = preg_replace('/[^a-z0-9]+/', '-', $value);
402 return trim((string) $value, '-');
403 }
404
405 /** Fail before persistence when an asset contains an unknown placeholder. */
406 protected function assert_resolved(array $template)
407 {
408 $encoded = wp_json_encode($template);
409 if (strpos($encoded, '__CM_') !== false) {
410 throw new Exception(esc_html__('Content Manager template contains an unresolved binding.', 'kirki'));
411 }
412
413 foreach ($template['blocks'] ?? [] as $block) {
414 $dynamic = $block['properties']['dynamicContent'] ?? null;
415 if (
416 is_array($dynamic)
417 && $this->is_exported_content_manager_binding($dynamic)
418 && !isset($this->fields_by_id[(string) ($dynamic['value'] ?? '')])
419 ) {
420 throw new Exception(esc_html__('Content Manager template contains a foreign field binding.', 'kirki'));
421 }
422 }
423 }
424 }
425