dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxContentRenderer.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxContent;
3
4require_once __DIR__ . '/dbxContentLng.class.php';
5require_once __DIR__ . '/dbxContentMediaUsageScope.class.php';
6
8
9 private $dd_media = 'dbxMedia';
10 private $dd_media_usage = 'dbxMediaUsage';
11 private array $folderRowCache = array();
12
14 private function folder_row($db, int $folderId): array {
15 if ($folderId <= 0) return array();
16 $key = dbxContentLng::ddFolder() . ':' . $folderId;
17 if (!array_key_exists($key, $this->folderRowCache)) {
18 $row = $db->select1(dbxContentLng::ddFolder(), $folderId, '*', 0);
19 $this->folderRowCache[$key] = is_array($row) ? $row : array();
20 }
21 return $this->folderRowCache[$key];
22 }
23
29 public static function optimizeContentPageImages(string $html): string {
30 $priorityAssigned = false;
31 $result = preg_replace_callback('/<img\b[^>]*>/i', static function(array $match) use (&$priorityAssigned): string {
32 $tag = (string)($match[0] ?? '');
33 if ($tag === '') return $tag;
34
35 if (!$priorityAssigned) {
36 $tag = self::withHtmlImageAttr($tag, 'loading', 'eager', true);
37 $tag = self::withHtmlImageAttr($tag, 'fetchpriority', 'high', true);
38 $priorityAssigned = true;
39 } else {
40 // Pro Content-Seite genau ein moegliches LCP-Bild priorisieren.
41 // Auch versehentlich gespeicherte eager-Attribute werden bei allen
42 // nachfolgenden Bildern auf lazy/low normalisiert.
43 $tag = self::withHtmlImageAttr($tag, 'loading', 'lazy', true);
44 $tag = self::withHtmlImageAttr($tag, 'fetchpriority', 'low', true);
45 }
46
47 return self::withHtmlImageAttr($tag, 'decoding', 'async');
48 }, $html);
49
50 return is_string($result) ? $result : $html;
51 }
52
53 private static function withHtmlImageAttr(string $tag, string $name, string $value, bool $replace = false): string {
54 $attrPattern = '/\s' . preg_quote($name, '/') . '\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i';
55 $attribute = ' ' . $name . '="' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '"';
56 if (preg_match($attrPattern, $tag)) {
57 if (!$replace) return $tag;
58 $updated = preg_replace($attrPattern, $attribute, $tag, 1);
59 return is_string($updated) ? $updated : $tag;
60 }
61
62 $closing = str_ends_with(rtrim($tag), '/>') ? '/>' : '>';
63 $trimmed = rtrim($tag);
64 return substr($trimmed, 0, -strlen($closing)) . $attribute . $closing;
65 }
66
67 public function render($cid) {
68 return $this->interpretContentModules($this->renderStatic($cid));
69 }
70
71 public function renderNotFound(): string {
72 $tpl = dbx()->get_system_obj('dbxTPL');
73 $permalink = trim((string)dbx()->get_system_var('dbx_permalink', ''), '/');
74 return $tpl->get_tpl('dbxContent|no-page', array(
75 'permalink' => dbx()->esc($permalink !== '' ? $permalink : '/'),
76 ));
77 }
78
79 public function renderStatic($cid, array $options = array()) {
80 $cid = (int)$cid;
81 if ($cid <= 0) return 'Keine dbx_cid gesetzt!';
82
83 $db = dbx()->get_system_obj('dbxDB');
84 $tpl = dbx()->get_system_obj('dbxTPL');
85 $rec = $db->select1(dbxContentLng::ddContent(), $cid, '*', 0);
86 if (!is_array($rec) || (int)($rec['id'] ?? 0) <= 0) {
87 return $this->renderNotFound();
88 }
89
90 $rights = $this->resolve_content_rights($db, $rec);
91 if (!dbx()->can($rights)) {
92 if (empty($options['admin_help']) || !dbx()->can('admin')) {
93 return '<div class="alert alert-warning" role="alert">Sie haben keinen Zugriff auf diese Seite.</div>';
94 }
95 }
96
97 if (empty($options['skip_hits'])) {
98 $this->update_hits($db, $rec);
99 }
100
101 $forceTemplate = trim((string)($options['template'] ?? ''));
102 if ($forceTemplate !== '') {
103 $template = $this->normalize_content_template($forceTemplate);
104 } else {
105 $template = $this->normalize_content_template($this->resolve_content_setting($db, $rec, 'template', 'template', 'c-content'));
106 }
107
108 $this->applySeoMeta($cid, $rec);
109
110 $template_html = $tpl->get_tpl('dbxContent|' . $template);
111 $slots = $this->detect_template_slots($template_html);
112 $content_html = $this->convert_mod_placeholders((string)($rec['content'] ?? ''));
113 $parsed = $this->parse_content($content_html, $slots);
114 $parsed = $this->render_inline_media_placeholders($db, $parsed);
115 $cms_cols = max(1, min(3, (int)($slots['cols'] ?? 1)));
116
117 $settings = $this->content_settings($db, $rec);
118 $hero_classes = array(
119 $this->css_class_token('hero-template', $settings['hero_template'] ?? 'image-hero'),
120 $this->css_class_token('hero-variant', $settings['hero_variant'] ?? 'original'),
121 $this->is_enabled_value($settings['hero_sticky'] ?? '0') ? 'hero-sticky' : 'hero-not-sticky',
122 $this->css_class_token('hero-scroll', $settings['hero_scroll_layer'] ?? 'under'),
123 );
124 $gallery_classes = array(
125 $this->css_class_token('gallery-template', $settings['gallery_template'] ?? 'image-gallery'),
126 $this->css_class_token('gallery-overflow', $settings['gallery_overflow'] ?? 'grid'),
127 $this->css_class_token('gallery-click', $settings['gallery_click_behavior'] ?? 'lightbox'),
128 $this->css_class_token('gallery-count', $settings['gallery_visible_count'] ?? '3'),
129 $this->css_class_token('gallery-size', $settings['gallery_image_size'] ?? 'original'),
130 $this->css_class_token('gallery-lightbox-width', $settings['gallery_lightbox_width'] ?? '100vw'),
131 );
132 $hero_style = $this->css_custom_properties(array(
133 'cms-hero-margin-top' => $this->css_length_value($settings['hero_margin_top'] ?? '0', '0'),
134 'cms-hero-height' => $this->css_length_value($settings['hero_height'] ?? 'auto', 'auto'),
135 ) + $this->hero_content_custom_properties($settings['hero_height'] ?? 'auto'));
136 $gallery_style = $this->css_custom_properties(array(
137 'dbx-gallery-visible-count' => (string)max(1, min(12, (int)($settings['gallery_visible_count'] ?? 3))),
138 'dbx-gallery-aspect-ratio' => $this->gallery_aspect_ratio($settings['gallery_image_size'] ?? 'original'),
139 'dbx-gallery-lightbox-width' => $this->css_length_value($settings['gallery_lightbox_width'] ?? '100vw', '100vw'),
140 ));
141
142 $merge = array(
143 'i' => dbx()->next_id(),
144 'id' => (string)($rec['id'] ?? ''),
145 'title' => (string)($rec['title'] ?? ''),
146 'description' => (string)($rec['description'] ?? ''),
147 'keywords' => (string)($rec['keywords'] ?? ''),
148 'permalink' => (string)($rec['permalink'] ?? ''),
149 'template' => $template,
150 'content' => $parsed['content'],
151 'body' => $parsed['body'],
152 'h1' => $parsed['h1'],
153 'header' => $parsed['header'],
154 'teaser' => $parsed['teaser'],
155 'thesar' => (string)($rec['thesar'] ?? ''),
156 'footer' => $parsed['footer'],
157 'col_1' => $parsed['col1'],
158 'col_2' => $parsed['col2'],
159 'col_3' => $parsed['col3'],
160 'content:h1' => $parsed['h1'],
161 'content:header' => $parsed['header'],
162 'content:hero' => $parsed['hero'],
163 'content:teaser' => $parsed['teaser'],
164 'content:thesar' => $parsed['teaser'],
165 'content:footer' => $parsed['footer'],
166 'content:body' => $parsed['body'],
167 'content:col1' => $parsed['col1'],
168 'content:col2' => $parsed['col2'],
169 'content:col3' => $parsed['col3'],
170 'cms:title' => $this->render_title_slot($tpl, $template_html, (string)($rec['title'] ?? '')),
171 'cms:header' => $this->render_inline($tpl, 'header', $parsed['header']),
172 'cms:teaser' => $this->render_inline($tpl, 'header', $parsed['header'], 'header'),
173 'cms:content' => $this->render_inline($tpl, 'content', $parsed['content']),
174 'cms:cols' => (string)$cms_cols,
175 'cms:hero_class' => 'no-hero',
176 'cms:gallery_class' => 'no-gallery',
177 'cms:hero' => '',
178 'cms:gallery' => '',
179 'cms:footer' => $this->render_inline($tpl, 'footer', $parsed['footer']),
180 'cms:col1' => $cms_cols === 1 ? $parsed['body'] : $this->render_inline($tpl, 'col', $parsed['col1'], 'col'),
181 'cms:col2' => $this->render_inline($tpl, 'col', $parsed['col2'], 'col'),
182 'cms:col3' => $this->render_inline($tpl, 'col', $parsed['col3'], 'col'),
183 );
184
185 $merge = array_merge($merge, array(
186 'hero:template' => htmlspecialchars((string)($settings['hero_template'] ?? 'image-hero'), ENT_QUOTES, 'UTF-8'),
187 'hero:image_id' => htmlspecialchars((string)($settings['hero_image_id'] ?? ''), ENT_QUOTES, 'UTF-8'),
188 'hero:margin_top' => htmlspecialchars((string)($settings['hero_margin_top'] ?? '0'), ENT_QUOTES, 'UTF-8'),
189 'hero:height' => htmlspecialchars((string)($settings['hero_height'] ?? 'auto'), ENT_QUOTES, 'UTF-8'),
190 'hero:variant' => htmlspecialchars((string)($settings['hero_variant'] ?? 'original'), ENT_QUOTES, 'UTF-8'),
191 'hero:sticky' => htmlspecialchars((string)($settings['hero_sticky'] ?? '0'), ENT_QUOTES, 'UTF-8'),
192 'hero:scroll_layer' => htmlspecialchars((string)($settings['hero_scroll_layer'] ?? 'under'), ENT_QUOTES, 'UTF-8'),
193 'hero:class' => implode(' ', array_filter($hero_classes)),
194 'hero:style' => $hero_style,
195 'gallery:template' => htmlspecialchars((string)($settings['gallery_template'] ?? 'image-gallery'), ENT_QUOTES, 'UTF-8'),
196 'gallery:visible_count' => htmlspecialchars((string)($settings['gallery_visible_count'] ?? '3'), ENT_QUOTES, 'UTF-8'),
197 'gallery:image_size' => htmlspecialchars((string)($settings['gallery_image_size'] ?? 'original'), ENT_QUOTES, 'UTF-8'),
198 'gallery:lightbox_width' => htmlspecialchars($this->css_length_value($settings['gallery_lightbox_width'] ?? '100vw', '100vw'), ENT_QUOTES, 'UTF-8'),
199 'gallery:overflow' => htmlspecialchars((string)($settings['gallery_overflow'] ?? 'grid'), ENT_QUOTES, 'UTF-8'),
200 'gallery:click_behavior' => htmlspecialchars((string)($settings['gallery_click_behavior'] ?? 'lightbox'), ENT_QUOTES, 'UTF-8'),
201 'gallery:class' => implode(' ', array_filter($gallery_classes)),
202 'gallery:style' => $gallery_style,
203 ));
204 $merge = array_merge($merge, $this->render_media_merge($db, $tpl, $cid, $slots, $rec, $settings));
205 $hero_media = trim((string)($merge['media:hero'] ?? ''));
206 $hero_content = $this->render_inline($tpl, 'hero', $parsed['hero'], 'hero');
207 $hero_content = trim((string)$hero_content);
208 $merge['cms:hero'] = $hero_media
209 . ($hero_content !== '' ? '<div class="hero-content">' . $hero_content . '</div>' : '');
210 $merge['cms:gallery'] = $merge['media:gallery'] ?? '';
211 $hero_state_classes = array(
212 ($hero_media !== '' || $hero_content !== '') ? 'has-hero' : 'no-hero',
213 $hero_media !== '' ? 'has-hero-media' : '',
214 $hero_content !== '' ? 'has-hero-content' : '',
215 );
216 $merge['cms:hero_class'] = trim(implode(' ', array_filter($hero_state_classes)) . ' ' . ($merge['hero:class'] ?? ''));
217 $merge['cms:gallery_class'] = trim((trim($merge['cms:gallery']) !== '' ? 'has-gallery ' : 'no-gallery ') . ($merge['gallery:class'] ?? ''));
218 $has_gallery_slot = !empty($slots['media']['gallery']) || !empty($slots['cms']['gallery']);
219 $has_gallery_media = trim((string)($merge['cms:gallery'] ?? '')) !== '';
220 $merge['gallery:data_dbx'] = $this->gallery_data_dbx_attrs($settings, $has_gallery_slot, $has_gallery_media);
221 $content_html = $this->replace_content_markers($template_html, $merge);
222 $content_html = $this->cleanup_empty_sections($content_html);
223 $content_html = $this->decorate_brand_text($content_html);
224 $content_html = $tpl->replaces($content_html, $merge);
225 return $content_html;
226 }
227
228 private function decorate_brand_text(string $html): string {
229 if ($html === '' || strpos($html, 'dbXapp') === false) {
230 return $html;
231 }
232
233 return $this->replace_text_segments($html, function(string $text): string {
234 return str_replace('dbXapp', 'db<span style="color:red">X</span>app', $text);
235 });
236 }
237
238 private function replace_text_segments(string $html, callable $callback): string {
239 $parts = preg_split('/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
240 if (!is_array($parts)) {
241 return (string)$callback($html);
242 }
243
244 foreach ($parts as $idx => $part) {
245 if ($part === '' || $part[0] === '<') {
246 continue;
247 }
248 $parts[$idx] = (string)$callback($part);
249 }
250
251 return implode('', $parts);
252 }
253
254 public function applySeoMeta(int $cid, array $rec = null, array $meta = null) {
255 $cid = (int)$cid;
256 if ($cid <= 0) return;
257
258 $db = dbx()->get_system_obj('dbxDB');
259 if (!is_array($rec) || (int)($rec['id'] ?? 0) <= 0) {
260 if (is_array($meta) && !empty($meta['seo']) && is_array($meta['seo'])) {
261 $rec = array_merge(array('id' => $cid), $meta['seo']);
262 } else {
263 $rec = $db->select1(dbxContentLng::ddContent(), $cid, '*', 0);
264 if (!is_array($rec) || (int)($rec['id'] ?? 0) <= 0) return;
265 }
266 }
267
268 $pageTitle = trim((string)($rec['title'] ?? ''));
269 $seoTitle = trim((string)($rec['seo_title'] ?? ''));
270 $displayTitle = $seoTitle !== '' ? $seoTitle : $pageTitle;
271 dbx()->set_system_var('dbx_title', $displayTitle);
272
273 $description = trim((string)($rec['description'] ?? ''));
274 if ($description === '') {
275 $description = $this->seoExcerptFromContent((string)($rec['content'] ?? ''));
276 }
277
278 $keywords = trim((string)($rec['keywords'] ?? ''));
279 $currentLng = strtolower(trim((string)dbx()->get_system_var('dbx_lng', 'de')));
280 if ($currentLng === '') $currentLng = 'de';
281 $isHomePage = $currentLng === dbxContentLngSync::masterLng()
282 && dbxContentHome::masterCid() === $cid;
283 $canonical = $this->seoCanonicalUrl((string)($rec['permalink'] ?? ''), $isHomePage);
284 $activ = (int)($rec['activ'] ?? 1);
285 $metaRobots = trim((string)($rec['meta_robots'] ?? ''));
286 if ($metaRobots === '') {
287 $metaRobots = ($activ === 0) ? 'noindex,nofollow' : 'index,follow';
288 } elseif ($activ === 0 && stripos($metaRobots, 'noindex') === false) {
289 $metaRobots = 'noindex,nofollow';
290 }
291
292 $og_title = $displayTitle;
293 $og_image = $this->seoOgImageUrl($db, $rec);
294 dbx()->set_system_var('dbx_meta_description', $description);
295 dbx()->set_system_var('dbx_meta_keywords', $keywords);
296 dbx()->set_system_var('dbx_canonical', $canonical);
297 dbx()->set_system_var('dbx_robots', $metaRobots);
298 dbx()->set_system_var('dbx_og_title', $og_title);
299 dbx()->set_system_var('dbx_og_description', $description);
300 dbx()->set_system_var('dbx_og_url', $canonical);
301 dbx()->set_system_var('dbx_og_image', $og_image);
302 dbx()->set_system_var('dbx_hreflang', $this->seoHreflangBlock($db, $rec, $currentLng));
303 dbx()->set_system_var('dbx_json_ld', $this->seoJsonLd($rec, $displayTitle, $description, $canonical, $currentLng));
304 }
305
306 public static function seoMetaFromRecord(array $rec): array {
307 return array(
308 'title' => (string)($rec['title'] ?? ''),
309 'seo_title' => (string)($rec['seo_title'] ?? ''),
310 'description' => (string)($rec['description'] ?? ''),
311 'keywords' => (string)($rec['keywords'] ?? ''),
312 'meta_robots' => (string)($rec['meta_robots'] ?? ''),
313 'seo_image_id' => (int)($rec['seo_image_id'] ?? 0),
314 'permalink' => (string)($rec['permalink'] ?? ''),
315 'activ' => (int)($rec['activ'] ?? 1),
316 'update_date' => (string)($rec['update_date'] ?? ''),
317 'lng_uid' => (string)($rec['lng_uid'] ?? ''),
318 );
319 }
320
321 public static function resetSeoMeta() {
322 foreach (array(
323 'dbx_meta_description',
324 'dbx_meta_keywords',
325 'dbx_canonical',
326 'dbx_robots',
327 'dbx_og_title',
328 'dbx_og_description',
329 'dbx_og_url',
330 'dbx_og_image',
331 'dbx_hreflang',
332 'dbx_json_ld',
333 ) as $key) {
334 dbx()->set_system_var($key, '');
335 }
336 }
337
339 return $this->interpret_content_modules($html);
340 }
341
342 private function interpret_content_modules($html) {
343 $html = (string)$html;
344 if ($html === '' || stripos($html, '[modul=') === false) {
345 return $html;
346 }
347 $interpreter = dbx()->get_system_obj('dbxInterpreter');
348 if (!is_object($interpreter) || !method_exists($interpreter, 'run')) {
349 return $html;
350 }
351 return $interpreter->run($html);
352 }
353
354 public function getPublicFolderRights(int $folder_id): string {
355 $db = dbx()->get_system_obj('dbxDB');
356 return $this->resolve_folder_rights($db, $folder_id);
357 }
358
359 private function resolve_content_rights($db, array $rec): string {
360 // Content-Seiten besitzen keine eigenen Leserechte. Ausschliesslich die
361 // effektiven, gegebenenfalls vererbten Rechte ihres Ordners entscheiden.
362 return $this->resolve_folder_rights($db, (int)($rec['folder'] ?? 0));
363 }
364
365 private function resolve_folder_rights($db, $folder_id, array $visited = array()) {
366 $folder_id = (int)$folder_id;
367 if ($folder_id <= 0) return '*';
368 if (isset($visited[$folder_id])) return '*';
369 $visited[$folder_id] = 1;
370
371 $rec = $this->folder_row($db, $folder_id);
372 if (!is_array($rec) || (int)($rec['id'] ?? 0) <= 0) return '*';
373
374 $parent_id = (int)($rec['parent_id'] ?? 0);
375 $raw = trim((string)($rec['group_read'] ?? ''));
376 if ($raw === '') return '*';
377
378 $parts = preg_split('/\s*,\s*/', $raw, -1, PREG_SPLIT_NO_EMPTY);
379 $out = array();
380 $uses_parent = false;
381
382 foreach ($parts as $part) {
383 $part = trim((string)$part);
384 if ($part === '') continue;
385 if (strtolower($part) === 'parent') {
386 $uses_parent = true;
387 continue;
388 }
389 $out[$part] = 1;
390 }
391
392 if ($uses_parent) {
393 $parent_rights = $this->resolve_folder_rights($db, $parent_id, $visited);
394 foreach (preg_split('/\s*,\s*/', $parent_rights, -1, PREG_SPLIT_NO_EMPTY) as $part) {
395 $part = trim((string)$part);
396 if ($part !== '' && strtolower($part) !== 'parent') $out[$part] = 1;
397 }
398 }
399
400 if (!count($out)) return '*';
401 return implode(',', array_keys($out));
402 }
403
404 private function is_parent_value($value) {
405 $value = trim((string)$value);
406 return $value === '' || strtolower($value) === 'parent';
407 }
408
409 private function is_no_hero_value($value) {
410 $value = strtolower(trim((string)$value));
411 return in_array($value, array('none', 'no-hero', '0', 'off'), true);
412 }
413
414 private function is_enabled_value($value) {
415 $value = strtolower(trim((string)$value));
416 return in_array($value, array('1', 'true', 'on', 'yes', 'sticky'), true);
417 }
418
419 private function resolve_folder_setting($db, $folder_id, $field, $fallback, array $visited = array()) {
420 $folder_id = (int)$folder_id;
421 if ($folder_id <= 0 || isset($visited[$folder_id])) return $fallback;
422 $visited[$folder_id] = 1;
423
424 $rec = $this->folder_row($db, $folder_id);
425 if (!is_array($rec) || (int)($rec['id'] ?? 0) <= 0) return $fallback;
426
427 $value = trim((string)($rec[$field] ?? ''));
428 if (!$this->is_parent_value($value)) return $value;
429 return $this->resolve_folder_setting($db, (int)($rec['parent_id'] ?? 0), $field, $fallback, $visited);
430 }
431
432 private function resolve_content_setting($db, array $rec, $content_field, $folder_field, $fallback) {
433 $value = trim((string)($rec[$content_field] ?? ''));
434 if (!$this->is_parent_value($value)) return $value;
435 return $this->resolve_folder_setting($db, (int)($rec['folder'] ?? 0), $folder_field, $fallback);
436 }
437
438 private function local_content_setting(array $rec, $field, $fallback) {
439 $value = trim((string)($rec[$field] ?? ''));
440 return $this->is_parent_value($value) ? $fallback : $value;
441 }
442
443 private function content_settings($db, array $rec) {
444 return array(
445 'hero_template' => $this->resolve_content_setting($db, $rec, 'hero_template', 'hero_template', 'image-hero'),
446 'hero_image_id' => $this->resolve_content_setting($db, $rec, 'hero_image_id', 'hero_image_id', ''),
447 'hero_margin_top' => $this->resolve_content_setting($db, $rec, 'hero_margin_top', 'hero_margin_top', '0'),
448 'hero_height' => $this->resolve_content_setting($db, $rec, 'hero_height', 'hero_height', 'auto'),
449 'hero_variant' => $this->resolve_content_setting($db, $rec, 'hero_variant', 'hero_variant', 'original'),
450 'hero_sticky' => $this->resolve_content_setting($db, $rec, 'hero_sticky', 'hero_sticky', '0'),
451 'hero_scroll_layer' => $this->resolve_content_setting($db, $rec, 'hero_scroll_layer', 'hero_scroll_layer', 'under'),
452 'gallery_template' => 'image-gallery',
453 'gallery_visible_count' => '3',
454 'gallery_image_size' => $this->local_content_setting($rec, 'gallery_image_size', 'original'),
455 'gallery_lightbox_width' => $this->local_content_setting($rec, 'gallery_lightbox_width', '100vw'),
456 'gallery_overflow' => $this->local_content_setting($rec, 'gallery_overflow', 'grid'),
457 'gallery_click_behavior' => $this->local_content_setting($rec, 'gallery_click_behavior', 'lightbox'),
458 );
459 }
460
461 private function update_hits($db, array $rec) {
462 $hits = (int)($rec['hits'] ?? 0);
463 if ($hits < 0 || empty($rec['id'])) return;
464 $db->update(dbxContentLng::ddContent(), array('hits' => $hits + 1), (int)$rec['id'], 0, 1, 1, 0);
465 }
466
467 private function get_content_tpl_from_folder($folder = 0) {
468 $folder = (int)$folder;
469 $db = dbx()->get_system_obj('dbxDB');
470 while ($folder > 0) {
471 $rec = $this->folder_row($db, $folder);
472 if (!is_array($rec) || (int)($rec['id'] ?? 0) <= 0) break;
473 $tpl = trim((string)($rec['template'] ?? ''));
474 if ($tpl !== '') return $tpl;
475 $folder = (int)($rec['parent_id'] ?? 0);
476 }
477 return 'c-content';
478 }
479
480 private function content_template_exists($template) {
481 $template = trim((string)$template);
482 if ($template === '') return false;
483 $path = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/dbxContent/tpl/htm/' . $template . '.htm');
484 return is_file($path);
485 }
486
487 private function normalize_content_template($template) {
488 $template = trim((string)$template);
489 if ($template === '' || strtolower($template) === 'parent') $template = 'c-content';
490 if ($this->content_template_exists($template)) return $template;
491
492 $aliases = array(
493 'c-hero_header-gallery-body3-footer' => 'c-title-hero_header-gallery-body3-footer',
494 );
495 if (isset($aliases[$template]) && $this->content_template_exists($aliases[$template])) {
496 return $aliases[$template];
497 }
498
499 $template_key = strtolower($template);
500 $cols = strpos($template_key, 'col3') !== false ? '3' : (strpos($template_key, 'col2') !== false ? '2' : '');
501 $has_hero = strpos($template_key, 'hero') !== false;
502 $has_gallery = strpos($template_key, 'gallery') !== false;
503 $has_header = strpos($template_key, 'teaser') !== false || strpos($template_key, 'thesar') !== false;
504
505 if ($has_hero && $has_gallery && $cols === '3' && $this->content_template_exists('c-title-hero_header-gallery-body3-footer')) {
506 return 'c-title-hero_header-gallery-body3-footer';
507 }
508 if ($has_hero && $has_gallery) return 'c-hero-header-gallery-body' . $cols . '-footer';
509 if ($has_hero) return 'c-hero-header-body' . $cols . '-footer';
510 if ($has_gallery) return 'c-header-gallery-body' . $cols . '-footer';
511 if ($has_header || strpos($template_key, 'footer') !== false || $cols !== '') return 'c-header-body' . $cols . '-footer';
512 return 'c-content';
513 }
514
515 private function detect_template_slots($template_html) {
516 $template_html = (string)$template_html;
517 $slots = array(
518 'content' => array(),
519 'media' => array(),
520 'cols' => 1,
521 'cms' => array(),
522 );
523
524 if (preg_match_all('/\{content:([a-z0-9_]+)\}/i', $template_html, $m)) {
525 foreach ($m[1] as $slot) {
526 $slots['content'][strtolower($slot)] = 1;
527 }
528 }
529 if (preg_match_all('/\{media:([a-z0-9_]+)\}/i', $template_html, $m)) {
530 foreach ($m[1] as $slot) {
531 $slots['media'][strtolower($slot)] = 1;
532 }
533 }
534 if (preg_match_all('/\{cms:([a-z0-9_]+)\}/i', $template_html, $m)) {
535 foreach ($m[1] as $slot) {
536 $slot = strtolower($slot);
537 $slots['cms'][$slot] = 1;
538 if (in_array($slot, array('hero', 'gallery'), true)) $slots['media'][$slot] = 1;
539 if ($slot === 'hero') $slots['content']['hero'] = 1;
540 if ($slot === 'header') $slots['content']['header'] = 1;
541 if ($slot === 'teaser') $slots['content']['teaser'] = 1;
542 if ($slot === 'content') $slots['content']['content'] = 1;
543 if ($slot === 'footer') $slots['content']['footer'] = 1;
544 if (in_array($slot, array('col1', 'col2', 'col3'), true)) $slots['content'][$slot] = 1;
545 }
546 }
547
548 if (strpos($template_html, '{content}') !== false || strpos($template_html, '{content:body}') !== false) {
549 $slots['content']['content'] = 1;
550 }
551
552 if (isset($slots['content']['col3']) || strpos($template_html, '{col_3}') !== false || strpos($template_html, '{cms:col3}') !== false) $slots['cols'] = 3;
553 elseif (isset($slots['content']['col2']) || strpos($template_html, '{col_2}') !== false || strpos($template_html, '{cms:col2}') !== false) $slots['cols'] = 2;
554 elseif (isset($slots['content']['col1']) || strpos($template_html, '{col_1}') !== false || strpos($template_html, '{cms:col1}') !== false) $slots['cols'] = 1;
555 else $slots['cols'] = 1;
556
557 return $slots;
558 }
559
560 private function parse_content($html, array $slots) {
561 $blocks = $this->html_blocks($html);
562 $segments = $this->segment_content_blocks($blocks);
563
564 $hero_blocks = $segments['hero'];
565 $header_blocks = $segments['header'];
566 $footer_blocks = $segments['footer'];
567 $body_blocks = $segments['content'];
568
569 $has_hero_slot = !empty($slots['content']['hero']) || !empty($slots['cms']['hero']);
570 $has_header_slot = !empty($slots['content']['header']) || !empty($slots['content']['teaser'])
571 || !empty($slots['content']['thesar']) || !empty($slots['cms']['header']);
572 $has_footer_slot = !empty($slots['content']['footer']) || !empty($slots['cms']['footer']);
573
574 if (!$has_hero_slot) {
575 $body_blocks = array_merge($hero_blocks, $body_blocks);
576 $hero_blocks = array();
577 }
578 if (!$has_header_slot) {
579 $body_blocks = array_merge($header_blocks, $body_blocks);
580 $header_blocks = array();
581 }
582 if (!$has_footer_slot) {
583 $body_blocks = array_merge($body_blocks, $footer_blocks);
584 $footer_blocks = array();
585 }
586
587 $cols = max(1, min(3, (int)($slots['cols'] ?? 1)));
588 $col_blocks = $this->split_content_for_columns($body_blocks, $cols);
589 $all_content = $this->strip_dbx_markers($this->join_blocks($blocks));
590 $body_html = $this->strip_dbx_markers($this->join_blocks($body_blocks));
591
592 return array(
593 'h1' => '',
594 'hero' => $this->strip_dbx_markers($this->join_blocks($hero_blocks)),
595 'header' => $this->strip_dbx_markers($this->join_blocks($header_blocks)),
596 'teaser' => $this->strip_dbx_markers($this->join_blocks($header_blocks)),
597 'footer' => $this->strip_dbx_markers($this->join_blocks($footer_blocks)),
598 'body' => $body_html,
599 'content' => (isset($slots['content']['content']) || $cols === 1) ? $body_html : $all_content,
600 'col1' => $cols === 1 ? $body_html : $this->strip_dbx_markers($this->join_blocks($col_blocks[0] ?? array())),
601 'col2' => $this->strip_dbx_markers($this->join_blocks($col_blocks[1] ?? array())),
602 'col3' => $this->strip_dbx_markers($this->join_blocks($col_blocks[2] ?? array())),
603 );
604 }
605
606 private function html_blocks($html) {
607 $html = trim((string)$html);
608 if ($html === '') return array();
609
610 $pattern = '/(<!--\s*dbx:[^>]*?-->|<hr\b[^>]*>|<(h1|h2|h3|h4|h5|h6|p|ul|ol|blockquote|figure|table|div)\b[^>]*>.*?<\/\2>|<img\b[^>]*>)/is';
611 preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE);
612
613 $blocks = array();
614 $pos = 0;
615 foreach ($matches[0] as $idx => $match) {
616 $before = trim(substr($html, $pos, $match[1] - $pos));
617 if ($before !== '') $blocks[] = array('type' => 'html', 'html' => $before);
618
619 $chunk = $match[0];
620 $type = strtolower((string)($matches[2][$idx][0] ?? 'html'));
621 if (preg_match('/^<!--\s*dbx:/i', $chunk)) $type = 'marker';
622 elseif (preg_match('/^<hr/i', $chunk)) $type = $this->marker_name_from_html($chunk) !== '' ? 'marker' : 'break';
623 elseif (preg_match('/^<img/i', $chunk)) $type = 'img';
624
625 $blocks[] = array('type' => $type, 'html' => $chunk);
626 $pos = $match[1] + strlen($chunk);
627 }
628
629 $rest = trim(substr($html, $pos));
630 if ($rest !== '') $blocks[] = array('type' => 'html', 'html' => $rest);
631 return $blocks;
632 }
633
634 private function segment_content_blocks(array $blocks) {
635 $hero_idx = null;
636 $header_idx = null;
637 $footer_idx = null;
638 $footer_hr_idx = null;
639
640 foreach ($blocks as $idx => $block) {
641 if ($hero_idx === null && $this->is_dbx_marker($block, array('hero'))) {
642 $hero_idx = $idx;
643 }
644 if ($header_idx === null && $this->is_dbx_marker($block, array('header', 'teaser', 'thesar'))) {
645 $header_idx = $idx;
646 }
647 if ($this->is_dbx_marker($block, array('footer'))) {
648 if ($footer_idx === null) $footer_idx = $idx;
649 if ($footer_hr_idx === null && $this->is_hr_marker($block)) $footer_hr_idx = $idx;
650 }
651 }
652 if ($footer_hr_idx !== null) $footer_idx = $footer_hr_idx;
653
654 $result = array(
655 'hero' => array(),
656 'header' => array(),
657 'content' => array(),
658 'footer' => array(),
659 );
660 $n = count($blocks);
661 $has_hero_marker = $hero_idx !== null;
662
663 $markers = array();
664 if ($hero_idx !== null) $markers[] = array('type' => 'hero', 'idx' => $hero_idx);
665 if ($header_idx !== null) $markers[] = array('type' => 'header', 'idx' => $header_idx);
666 if ($footer_idx !== null) $markers[] = array('type' => 'footer', 'idx' => $footer_idx);
667 usort($markers, function($a, $b) {
668 return (int)$a['idx'] <=> (int)$b['idx'];
669 });
670
671 $pos = 0;
672 foreach ($markers as $marker) {
673 $idx = (int)$marker['idx'];
674 $gap = $idx > $pos ? array_slice($blocks, $pos, $idx - $pos) : array();
675 if ($marker['type'] === 'hero') {
676 $result['hero'] = $gap;
677 $pos = $idx + 1;
678 continue;
679 }
680 if ($marker['type'] === 'header') {
681 if ($has_hero_marker && $hero_idx < $idx) {
682 $result['header'] = $gap;
683 } else {
684 $result['content'] = array_merge($result['content'], $gap);
685 }
686 $pos = $idx + 1;
687 continue;
688 }
689 if ($marker['type'] === 'footer') {
690 $result['content'] = array_merge($result['content'], $gap);
691 $result['footer'] = array_slice($blocks, $idx + 1);
692 return $result;
693 }
694 }
695
696 if ($pos < $n) {
697 $result['content'] = array_merge($result['content'], array_slice($blocks, $pos));
698 }
699 return $result;
700 }
701
702 private function split_content_for_columns(array $blocks, $cols) {
703 $cols = max(1, min(3, (int)$cols));
704 $result = array_fill(0, $cols, array());
705 if ($cols === 1) {
706 $result[0] = $blocks;
707 return $result;
708 }
709
710 $marker_split = $this->split_by_column_markers($blocks, $cols);
711 if ($marker_split !== null) return $marker_split;
712
713 return $this->split_by_text_weight($blocks, $cols);
714 }
715
716 private function split_by_column_markers(array $blocks, $cols) {
717 $result = array_fill(0, $cols, array());
718 $current = 0;
719 $has = false;
720 foreach ($blocks as $block) {
721 if ($cols === 2 && $this->is_dbx_marker($block, array('col2', 'split'))) {
722 $has = true;
723 $current = 1;
724 continue;
725 }
726 if ($cols === 3 && $this->is_dbx_marker($block, array('col2', 'col3a'))) {
727 $has = true;
728 $current = 1;
729 continue;
730 }
731 if ($cols === 3 && $this->is_dbx_marker($block, array('col3', 'col3b'))) {
732 $has = true;
733 $current = 2;
734 continue;
735 }
736 $result[$current][] = $block;
737 }
738 return $has ? $result : null;
739 }
740
741 private function split_by_text_weight(array $blocks, $cols) {
742 $units = $this->column_units_from_blocks($blocks, $cols);
743 $result = array_fill(0, $cols, array());
744 if (empty($units)) return $result;
745
746 $parts = $this->partition_column_units($units, $cols);
747 $parts = $this->keep_leading_blanks_with_previous_column($parts);
748 foreach ($parts as $idx => $part) {
749 if ($idx >= $cols) break;
750 $result[$idx] = $this->column_units_to_blocks($part);
751 }
752 return $result;
753 }
754
755 private function column_units_from_blocks(array $blocks, $cols) {
756 $units = array();
757 $list_index = 0;
758 $paragraph_index = 0;
759
760 foreach ($blocks as $block) {
761 $type = strtolower((string)($block['type'] ?? 'html'));
762 if ($type === 'ul' || $type === 'ol') {
763 $list_units = $this->list_units_from_block($block, 'list-' . $list_index, $cols);
764 $list_index++;
765 if ($list_units !== null) {
766 $units = array_merge($units, $list_units);
767 continue;
768 }
769 }
770 if ($type === 'p') {
771 $paragraph_units = $this->paragraph_units_from_block($block, 'p-' . $paragraph_index, $cols);
772 $paragraph_index++;
773 if ($paragraph_units !== null) {
774 $units = array_merge($units, $paragraph_units);
775 continue;
776 }
777 }
778
779 $units[] = array(
780 'kind' => 'block',
781 'block' => $block,
782 'weight' => $this->estimate_block_weight($block, $cols),
783 );
784 }
785
786 return $units;
787 }
788
789 private function paragraph_units_from_block(array $block, $paragraph_key, $cols) {
790 $html = trim((string)($block['html'] ?? ''));
791 if ($html === '' || $this->is_blank_flow_html($html)) return null;
792 if (preg_match('/<(img|video|table|ul|ol|figure|blockquote|div|section|article|aside|header|footer|h[1-6]|pre)\b/i', $html)) return null;
793 if (!preg_match('/^<\s*p\b([^>]*)>([\s\S]*)<\/\s*p\s*>$/i', $html, $m)) return null;
794
795 $attrs = (string)$m[1];
796 $chunks = $this->inline_safe_chunks((string)$m[2], $cols);
797 if (count($chunks) < 5) return null;
798
799 $weights = array();
800 $total = 0;
801 foreach ($chunks as $chunk) {
802 $weight = $this->estimate_inline_chunk_weight($chunk, $cols);
803 $weights[] = $weight;
804 $total += $weight;
805 }
806
807 $threshold = max(260, ((int)$cols === 3 ? 240 : 320));
808 if ($total <= $threshold) return null;
809
810 $units = array();
811 foreach ($chunks as $idx => $chunk) {
812 if ($chunk === '') continue;
813 $units[] = array(
814 'kind' => 'paragraph_piece',
815 'paragraph_key' => $paragraph_key,
816 'attrs' => $attrs,
817 'html' => $chunk,
818 'weight' => max(0, (int)($weights[$idx] ?? 0)),
819 );
820 }
821
822 return count($units) > 1 ? $units : null;
823 }
824
825 private function inline_safe_chunks($inner_html, $cols) {
826 $tokens = preg_split('/(<[^>]+>)/', (string)$inner_html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
827 if (!is_array($tokens) || empty($tokens)) return array();
828
829 $chunks = array();
830 $count = count($tokens);
831 $chunk_limit = $this->column_chars_per_line($cols);
832 for ($i = 0; $i < $count; $i++) {
833 $token = $tokens[$i];
834 if (preg_match('/^<[^>]+>$/', $token)) {
835 $tag = $this->tag_name($token);
836 if ($tag !== '' && $this->is_opening_tag($token) && !$this->is_void_tag($tag) && !preg_match('/\/\s*>$/', $token)) {
837 $chunk = $token;
838 $depth = 1;
839 while ($depth > 0 && $i + 1 < $count) {
840 $i++;
841 $next = $tokens[$i];
842 $chunk .= $next;
843 if (!preg_match('/^<[^>]+>$/', $next)) continue;
844 $next_tag = $this->tag_name($next);
845 if ($next_tag !== $tag) continue;
846 if ($this->is_closing_tag($next)) {
847 $depth--;
848 } elseif ($this->is_opening_tag($next) && !preg_match('/\/\s*>$/', $next)) {
849 $depth++;
850 }
851 }
852 $chunks[] = $chunk;
853 continue;
854 }
855 $chunks[] = $token;
856 continue;
857 }
858
859 if (preg_match_all('/\S+\s*/u', $token, $words)) {
860 $buffer = '';
861 foreach ($words[0] as $word) {
862 $candidate = $buffer . $word;
863 if ($buffer !== '' && $this->text_length(strip_tags($candidate)) > $chunk_limit) {
864 $chunks[] = $buffer;
865 $buffer = $word;
866 } else {
867 $buffer = $candidate;
868 }
869 }
870 if ($buffer !== '') $chunks[] = $buffer;
871 }
872 }
873
874 return $chunks;
875 }
876
877 private function tag_name($tag) {
878 return preg_match('/^<\s*\/?\s*([a-z0-9]+)/i', (string)$tag, $m) ? strtolower($m[1]) : '';
879 }
880
881 private function is_opening_tag($tag) {
882 return preg_match('/^<\s*[a-z0-9]/i', (string)$tag) === 1;
883 }
884
885 private function is_closing_tag($tag) {
886 return preg_match('/^<\s*\//', (string)$tag) === 1;
887 }
888
889 private function is_void_tag($tag) {
890 return in_array(strtolower((string)$tag), array('area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr'), true);
891 }
892
893 private function estimate_inline_chunk_weight($html, $cols) {
894 $html = (string)$html;
895 $text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($html), ENT_QUOTES, 'UTF-8')));
896 $chars = $this->text_length($text);
897 $weight = (int)ceil(($chars / max(1, $this->column_chars_per_line($cols))) * 100);
898 $weight += substr_count(strtolower($html), '<br') * 100;
899 return max(1, $weight);
900 }
901
902 private function list_units_from_block(array $block, $list_key, $cols) {
903 $html = trim((string)($block['html'] ?? ''));
904 if (!preg_match('/^<\s*(ul|ol)\b([^>]*)>([\s\S]*)<\/\s*\1\s*>$/i', $html, $m)) return null;
905
906 $tag = strtolower($m[1]);
907 $attrs = (string)$m[2];
908 $items = $this->extract_top_level_list_items((string)$m[3]);
909 if (count($items) < 2) return null;
910
911 $units = array();
912 foreach ($items as $item_html) {
913 $units[] = array(
914 'kind' => 'list_item',
915 'list_key' => $list_key,
916 'tag' => $tag,
917 'attrs' => $attrs,
918 'html' => $item_html,
919 'weight' => $this->estimate_list_item_weight($item_html, $cols),
920 );
921 }
922 return $units;
923 }
924
925 private function extract_top_level_list_items($inner_html) {
926 $inner_html = (string)$inner_html;
927 if (!preg_match_all('/<li\b[^>]*>|<\/li\s*>/i', $inner_html, $matches, PREG_OFFSET_CAPTURE)) {
928 return array();
929 }
930
931 $items = array();
932 $depth = 0;
933 $start = null;
934
935 foreach ($matches[0] as $match) {
936 $tag = $match[0];
937 $offset = $match[1];
938 if (preg_match('/^<li\b/i', $tag)) {
939 if ($depth === 0) $start = $offset;
940 $depth++;
941 continue;
942 }
943
944 if ($depth === 0) continue;
945 $depth--;
946 if ($depth === 0 && $start !== null) {
947 $items[] = substr($inner_html, $start, $offset + strlen($tag) - $start);
948 $start = null;
949 }
950 }
951
952 return $depth === 0 ? $items : array();
953 }
954
955 private function estimate_list_item_weight($html, $cols) {
956 $nested_items = max(0, substr_count(strtolower((string)$html), '<li') - 1);
957 return max(100, $this->estimate_text_weight((string)$html, $cols, 1) + ($nested_items * 120));
958 }
959
960 private function partition_column_units(array $units, $cols) {
961 $cols = max(1, min(3, (int)$cols));
962 $n = count($units);
963 if ($cols === 1 || $n === 0) return array($units);
964
965 if ($n < $cols) {
966 $parts = array_fill(0, $cols, array());
967 foreach ($units as $idx => $unit) $parts[$idx][] = $unit;
968 return $parts;
969 }
970
971 $prefix = array(0);
972 foreach ($units as $unit) {
973 $prefix[] = end($prefix) + max(0, (int)($unit['weight'] ?? 0));
974 }
975
976 $total = $prefix[$n];
977 $target = $total > 0 ? $total / $cols : 1;
978 $inf = PHP_INT_MAX;
979 $dp = array();
980 $prev = array();
981
982 for ($c = 0; $c <= $cols; $c++) {
983 $dp[$c] = array_fill(0, $n + 1, $inf);
984 $prev[$c] = array_fill(0, $n + 1, -1);
985 }
986
987 for ($i = 1; $i <= $n; $i++) {
988 $dp[1][$i] = $this->column_partition_cost($prefix, 0, $i, $target);
989 $prev[1][$i] = 0;
990 }
991
992 for ($c = 2; $c <= $cols; $c++) {
993 for ($i = $c; $i <= $n; $i++) {
994 for ($j = $c - 1; $j < $i; $j++) {
995 if ($dp[$c - 1][$j] === $inf) continue;
996 $cost = $dp[$c - 1][$j] + $this->column_partition_cost($prefix, $j, $i, $target);
997 if ($cost < $dp[$c][$i]) {
998 $dp[$c][$i] = $cost;
999 $prev[$c][$i] = $j;
1000 }
1001 }
1002 }
1003 }
1004
1005 $parts = array_fill(0, $cols, array());
1006 $i = $n;
1007 for ($c = $cols; $c >= 1; $c--) {
1008 $j = $prev[$c][$i];
1009 if ($j < 0) $j = max(0, $i - 1);
1010 $parts[$c - 1] = array_slice($units, $j, $i - $j);
1011 $i = $j;
1012 }
1013
1014 return $parts;
1015 }
1016
1017 private function keep_leading_blanks_with_previous_column(array $parts) {
1018 for ($i = 1; $i < count($parts); $i++) {
1019 while (!empty($parts[$i]) && $this->is_blank_column_unit($parts[$i][0])) {
1020 $parts[$i - 1][] = array_shift($parts[$i]);
1021 }
1022 }
1023 return $parts;
1024 }
1025
1026 private function is_blank_column_unit(array $unit) {
1027 if (($unit['kind'] ?? '') !== 'block' || !isset($unit['block']) || !is_array($unit['block'])) return false;
1028 $block = $unit['block'];
1029 return strtolower((string)($block['type'] ?? '')) === 'p' && $this->is_blank_flow_html((string)($block['html'] ?? ''));
1030 }
1031
1032 private function column_partition_cost(array $prefix, $start, $end, $target) {
1033 $sum = $prefix[$end] - $prefix[$start];
1034 $diff = $sum - $target;
1035 return $diff * $diff;
1036 }
1037
1038 private function column_units_to_blocks(array $units) {
1039 $blocks = array();
1040 $pending = null;
1041
1042 foreach ($units as $unit) {
1043 if (($unit['kind'] ?? '') === 'list_item') {
1044 $key = (string)($unit['list_key'] ?? '');
1045 if ($pending === null || ($pending['kind'] ?? '') !== 'list' || $pending['list_key'] !== $key) {
1046 $this->flush_pending_column_group($pending, $blocks);
1047 $pending = array(
1048 'kind' => 'list',
1049 'list_key' => $key,
1050 'tag' => (string)($unit['tag'] ?? 'ul'),
1051 'attrs' => (string)($unit['attrs'] ?? ''),
1052 'items' => array(),
1053 );
1054 }
1055 $pending['items'][] = (string)($unit['html'] ?? '');
1056 continue;
1057 }
1058 if (($unit['kind'] ?? '') === 'paragraph_piece') {
1059 $key = (string)($unit['paragraph_key'] ?? '');
1060 if ($pending === null || ($pending['kind'] ?? '') !== 'paragraph' || $pending['paragraph_key'] !== $key) {
1061 $this->flush_pending_column_group($pending, $blocks);
1062 $pending = array(
1063 'kind' => 'paragraph',
1064 'paragraph_key' => $key,
1065 'attrs' => (string)($unit['attrs'] ?? ''),
1066 'pieces' => array(),
1067 );
1068 }
1069 $pending['pieces'][] = (string)($unit['html'] ?? '');
1070 continue;
1071 }
1072 $this->flush_pending_column_group($pending, $blocks);
1073 if (isset($unit['block']) && is_array($unit['block'])) $blocks[] = $unit['block'];
1074 }
1075
1076 $this->flush_pending_column_group($pending, $blocks);
1077 return $blocks;
1078 }
1079
1080 private function flush_pending_column_group(&$pending, array &$blocks) {
1081 if ($pending === null) return;
1082 $kind = (string)($pending['kind'] ?? '');
1083 $tag = (string)($pending['tag'] ?? '');
1084 if ($kind === 'list') {
1085 $tag = preg_match('/^(ul|ol)$/i', $tag) ? strtolower($tag) : 'ul';
1086 $html = '<' . $tag . (string)$pending['attrs'] . '>' . implode('', $pending['items']) . '</' . $tag . '>';
1087 } elseif ($kind === 'paragraph') {
1088 $tag = 'p';
1089 $html = '<p' . (string)$pending['attrs'] . '>' . implode('', $pending['pieces']) . '</p>';
1090 } else {
1091 $pending = null;
1092 return;
1093 }
1094 $blocks[] = array(
1095 'type' => $tag,
1096 'html' => $html,
1097 );
1098 $pending = null;
1099 }
1100
1101 private function estimate_block_weight(array $block, $cols) {
1102 $html = (string)($block['html'] ?? '');
1103 $type = strtolower((string)($block['type'] ?? 'html'));
1104
1105 if ($type === 'marker') return 0;
1106 if ($type === 'img') return $this->estimate_media_weight($html, $cols);
1107 if ($type === 'figure') return $this->estimate_media_weight($html, $cols) + min(300, $this->estimate_text_weight($html, $cols, 0));
1108 if ($type === 'table') return 500 + substr_count(strtolower($html), '<tr') * 100;
1109 if ($type === 'ul' || $type === 'ol') return max(200, substr_count(strtolower($html), '<li') * 100 + $this->estimate_text_weight($html, $cols, 0));
1110 if ($type === 'p' && preg_match('/<(img|video)\b/i', $html)) return $this->estimate_media_weight($html, $cols) + min(300, $this->estimate_text_weight($html, $cols, 0));
1111 if ($type === 'p' && $this->is_blank_flow_html($html)) return 0;
1112 if (in_array($type, array('h1', 'h2', 'h3', 'h4', 'h5', 'h6'), true)) return max(130, $this->estimate_text_weight($html, $cols, 1) + 60);
1113 if ($type === 'blockquote') return max(180, $this->estimate_text_weight($html, $cols, 1) + 80);
1114
1115 return max(60, $this->estimate_text_weight($html, $cols, 1));
1116 }
1117
1118 private function estimate_text_weight($html, $cols, $min_lines = 0) {
1119 $html = (string)$html;
1120 $text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($html), ENT_QUOTES, 'UTF-8')));
1121 $chars = $this->text_length($text);
1122 $lines = $chars > 0 ? ($chars / max(1, $this->column_chars_per_line($cols))) : 0;
1123 $lines += substr_count(strtolower($html), '<br');
1124 $lines = max((float)$min_lines, $lines);
1125 return (int)ceil($lines * 100);
1126 }
1127
1128 private function estimate_media_weight($html, $cols) {
1129 if (!preg_match_all('/<(img|video)\b[^>]*>/i', (string)$html, $matches)) {
1130 return $this->default_media_lines($cols) * 100;
1131 }
1132
1133 $weight = 0;
1134 foreach ($matches[0] as $tag) {
1135 $weight += $this->estimate_media_lines_from_tag($tag, $cols) * 100;
1136 }
1137 return max($this->default_media_lines($cols) * 100, $weight);
1138 }
1139
1140 private function estimate_media_lines_from_tag($tag, $cols) {
1141 $default = $this->default_media_lines($cols);
1142 $width = 0;
1143 $height = 0;
1144 if (preg_match('/\bwidth=["\']?([0-9.]+)/i', (string)$tag, $m)) $width = (float)$m[1];
1145 if (preg_match('/\bheight=["\']?([0-9.]+)/i', (string)$tag, $m)) $height = (float)$m[1];
1146 if ($width <= 0 || $height <= 0) return $default;
1147
1148 $ratio = $height / $width;
1149 $base = ((int)$cols === 3) ? 14 : 19;
1150 return max(8, min(24, (int)round($ratio * $base)));
1151 }
1152
1153 private function default_media_lines($cols) {
1154 return ((int)$cols === 3) ? 10 : 14;
1155 }
1156
1157 private function column_chars_per_line($cols) {
1158 if ((int)$cols === 3) return 42;
1159 if ((int)$cols === 2) return 58;
1160 return 78;
1161 }
1162
1163 private function text_length($text) {
1164 $text = (string)$text;
1165 return function_exists('mb_strlen') ? mb_strlen($text, 'UTF-8') : strlen($text);
1166 }
1167
1168 private function is_blank_flow_html($html) {
1169 $html = (string)$html;
1170 if (preg_match('/<(img|video|picture|source|iframe|embed|object|svg|canvas)\b/i', $html)) return false;
1171 $html = preg_replace('/<\/?p\b[^>]*>/i', '', $html);
1172 $html = preg_replace('/<br\s*\/?>/i', '', $html);
1173 $html = str_replace('&nbsp;', '', $html);
1174 return trim(html_entity_decode(strip_tags($html), ENT_QUOTES, 'UTF-8')) === '';
1175 }
1176
1177 private function join_blocks(array $blocks) {
1178 $html = '';
1179 foreach ($blocks as $block) {
1180 if ($this->is_dbx_marker($block, array('pagebreak'))) {
1181 $html .= '<div class="dbx-content-pagebreak" aria-hidden="true"></div>' . "\n";
1182 continue;
1183 }
1184 if (($block['type'] ?? '') === 'marker') continue;
1185 $html .= ($block['html'] ?? '') . "\n";
1186 }
1187 return trim($html);
1188 }
1189
1190 private function is_dbx_marker(array $block, array $names) {
1191 if (($block['type'] ?? '') !== 'marker') return false;
1192 $name = $this->marker_name_from_html((string)($block['html'] ?? ''));
1193 return $name !== '' && in_array($name, $names, true);
1194 }
1195
1196 private function is_hr_marker(array $block) {
1197 if (($block['type'] ?? '') !== 'marker') return false;
1198 return preg_match('/^\s*<hr\b/i', (string)($block['html'] ?? '')) === 1;
1199 }
1200
1201 private function marker_name_from_html($html) {
1202 $html = (string)$html;
1203 if (preg_match('/<!--\s*dbx:([a-z0-9_-]+)/i', $html, $m)) return $this->canonical_marker_name($m[1]);
1204 if (preg_match('/\bdata-dbx-marker=["\'](?:dbx:)?([a-z0-9_-]+)["\']/i', $html, $m)) return $this->canonical_marker_name($m[1]);
1205 if (preg_match('/\bclass=["\'][^"\']*\bdbx-cms-marker-([a-z0-9_-]+)\b/i', $html, $m)) return $this->canonical_marker_name($m[1]);
1206 if (preg_match('/\bclass=["\'][^"\']*\bdbx_marker_([a-z0-9_-]+)\b/i', $html, $m)) return $this->canonical_marker_name($m[1]);
1207 if (preg_match('/\bclass=["\'][^"\']*\bdbx_split\b/i', $html)) return 'col2';
1208 return '';
1209 }
1210
1211 private function canonical_marker_name($name) {
1212 $name = strtolower(trim((string)$name));
1213 if (in_array($name, array('hero_text', 'hero-text', 'herotext'), true)) return 'hero';
1214 return $name;
1215 }
1216
1217 private function convert_mod_placeholders($html) {
1218 $html = (string)$html;
1219 if ($html === '' || stripos($html, 'dbx-cms-mod-placeholder') === false) return $html;
1220
1221 return preg_replace_callback(
1222 '/<(p|figure|div)\b([^>]*\bdbx-cms-mod-placeholder\b[^>]*)>([\s\S]*?)<\/\1>/i',
1223 function($m) {
1224 $attrs = (string)($m[2] ?? '');
1225 $inner = (string)($m[3] ?? '');
1226 $modul = '';
1227 $params = '';
1228 $data_dbx = '';
1229 $alt = '';
1230
1231 if (preg_match('/\bdata-cms-mod-modul=["\']([^"\']*)["\']/i', $attrs, $mm)) {
1232 $modul = trim((string)$mm[1]);
1233 }
1234 if (preg_match('/\bdata-cms-mod-params=["\']([^"\']*)["\']/i', $attrs, $mm)) {
1235 $params = trim((string)$mm[1]);
1236 }
1237 if ($modul === '' && preg_match('/\bdata-cms-mod-modul=["\']([^"\']*)["\']/i', $inner, $mm)) {
1238 $modul = trim((string)$mm[1]);
1239 }
1240 if ($params === '' && preg_match('/\bdata-cms-mod-params=["\']([^"\']*)["\']/i', $inner, $mm)) {
1241 $params = trim((string)$mm[1]);
1242 }
1243 if ($modul === '' && preg_match('/<img\b[^>]*\bdata-cms-mod-modul=["\']([^"\']*)["\']/i', $inner, $mm)) {
1244 $modul = trim((string)$mm[1]);
1245 }
1246 if ($params === '' && preg_match('/<img\b[^>]*\bdata-cms-mod-params=["\']([^"\']*)["\']/i', $inner, $mm)) {
1247 $params = trim((string)$mm[1]);
1248 }
1249 if (preg_match('/<img\b[^>]*\bdata-dbx=["\']([^"\']*)["\']/i', $inner, $mm)) {
1250 $data_dbx = trim((string)$mm[1]);
1251 }
1252 if (preg_match('/<img\b[^>]*\balt=["\']([^"\']*)["\']/i', $inner, $mm)) {
1253 $alt = trim((string)$mm[1]);
1254 }
1255
1256 if (($modul === '' || $params === '') && preg_match('/modul_image[^"\']*file=([^"\'&]+)/i', $inner . $attrs, $fileMatch)) {
1257 $base = dirname(__DIR__, 3) . '/dbxAdmin/include/dbxModuleImages.class.php';
1258 if (is_file($base)) {
1259 require_once $base;
1260 $resolved = (new \dbx\dbxAdmin\dbxModuleImages())->resolveFromFilename(rawurldecode((string)$fileMatch[1]));
1261 if (is_array($resolved) && $resolved) {
1262 if ($modul === '') {
1263 $modul = (string)($resolved['default_modul'] ?? '');
1264 }
1265 if ($params === '') {
1266 $params = (string)($resolved['default_params'] ?? '');
1267 }
1268 }
1269 }
1270 }
1271
1272 $modul = preg_replace('/[^A-Za-z0-9_]+/', '', $modul);
1273 if ($modul === '') return $m[0];
1274
1275 $params = str_replace(array("\r", "\n"), '', $params);
1276 $marker = '[modul=' . $modul . ']' . $params . '[/modul]';
1277 $data_dbx = htmlspecialchars($data_dbx, ENT_QUOTES, 'UTF-8');
1278 $alt_attr = $alt !== '' ? ' title="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . '"' : '';
1279
1280 if ($data_dbx !== '') {
1281 return '<div class="dbx-cms-mod-output" data-dbx="' . $data_dbx . '"' . $alt_attr . '>' . $marker . '</div>';
1282 }
1283 return $marker;
1284 },
1285 $html
1286 );
1287 }
1288
1289 private function strip_dbx_markers($html) {
1290 $html = preg_replace('/<!--\s*dbx:[\s\S]*?-->/i', '', (string)$html);
1291 $html = preg_replace('/<hr\b[^>]*(?:data-dbx-marker|dbx-cms-marker|dbx_marker_|dbx_split)[^>]*>/i', '', $html);
1292 return trim($html);
1293 }
1294
1295 private function render_inline_media_placeholders($db, array $parsed) {
1296 foreach ($parsed as $key => $html) {
1297 if (!is_string($html)) continue;
1298 if (stripos($html, 'dbx-cms-inline-video-block') !== false) {
1299 $html = $this->render_inline_video_placeholders($db, $html);
1300 }
1301 if (stripos($html, 'dbx_mid=') !== false || stripos($html, 'data-cms-media-id') !== false) {
1302 $html = $this->render_inline_image_sanitize($db, $html);
1303 }
1304 $parsed[$key] = $html;
1305 }
1306 return $parsed;
1307 }
1308
1309 private function render_inline_image_sanitize($db, $html) {
1310 return preg_replace_callback('/<(img)\b([^>]*?)>/i', function($m) use ($db) {
1311 $attrs = (string)($m[2] ?? '');
1312 $id = 0;
1313 if (preg_match('/data-cms-media-id=["\']?([0-9]+)/i', $attrs, $id_match)) {
1314 $id = (int)$id_match[1];
1315 } elseif (preg_match('/dbx_mid=([0-9]+)/i', $attrs, $id_match)) {
1316 $id = (int)$id_match[1];
1317 }
1318 if ($id <= 0) return $m[0];
1319 $row = $db->select1($this->dd_media, $id);
1320 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1 || !$this->media_file_exists($row)) return '';
1321 $tag = (string)$m[0];
1322 $width = max(0, (int)($row['width'] ?? 0));
1323 $height = max(0, (int)($row['height'] ?? 0));
1324 if ($width > 0 && $height > 0) {
1325 $tag = self::withHtmlImageAttr($tag, 'width', (string)$width);
1326 $tag = self::withHtmlImageAttr($tag, 'height', (string)$height);
1327 }
1328 return $tag;
1329 }, (string)$html);
1330 }
1331
1332 private function render_inline_video_placeholders($db, $html) {
1333 return preg_replace_callback('/<figure\b([^>]*\bdbx-cms-inline-video-block\b[^>]*)>[\s\S]*?<\/figure>/i', function($m) use ($db) {
1334 $attrs = $m[1];
1335 if (!preg_match('/data-cms-media-id=["\']?([0-9]+)/i', $attrs, $id_match)) return $m[0];
1336 $id = (int)$id_match[1];
1337 $row = $db->select1($this->dd_media, $id);
1338 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1 || !$this->media_file_exists($row)) return '';
1339 return $this->render_inline_video_player($row, $attrs, $m[0]);
1340 }, (string)$html);
1341 }
1342
1343 private function inline_media_type(array $row) {
1344 if ((string)($row['media_type'] ?? '') === 'external_video') return 'external_video';
1345 $mime = (string)($row['mime'] ?? '');
1346 if (strpos($mime, 'video/') === 0 || preg_match('/\.(mp4|webm|ogv|ogg|mov|m4v)$/i', (string)($row['file_name'] ?? $row['file_path'] ?? ''))) return 'video';
1347 if (strtolower((string)($row['storage_type'] ?? '')) === 'external') return 'external_video';
1348 return 'file';
1349 }
1350
1351 private function render_inline_video_player(array $row, $source_attrs = '', $source_html = '') {
1352 $id = (int)($row['id'] ?? 0);
1353 $type = $this->inline_media_type($row);
1354 if ($id <= 0 || !in_array($type, array('video', 'external_video'), true)) return '';
1355
1356 $source_attrs = (string)$source_attrs;
1357 $source_html = (string)$source_html;
1358 $video_options = $this->inline_video_options_from_html($source_attrs, $source_html);
1359 $style = $video_options['style'] !== '' ? ' style="' . htmlspecialchars($video_options['style'], ENT_QUOTES, 'UTF-8') . '"' : '';
1360 $autoplay = !empty($video_options['autoplay']);
1361 $loop = !empty($video_options['loop']);
1362 $muted = !empty($video_options['muted']);
1363 $align = (string)($video_options['align'] ?? 'left');
1364 if ($autoplay) {
1365 $muted = true;
1366 }
1367
1368 $title = htmlspecialchars((string)($row['title'] ?? $row['alt'] ?? $row['file_name'] ?? 'Video'), ENT_QUOTES, 'UTF-8');
1369 $caption = trim((string)($row['caption'] ?? ''));
1370 $caption_html = $caption !== '' ? '<figcaption>' . htmlspecialchars($caption, ENT_QUOTES, 'UTF-8') . '</figcaption>' : '';
1371 $figure_attr = ' class="dbx-content-inline-media dbx-content-inline-video" data-media-id="' . $id . '" data-video-align="' . htmlspecialchars($align, ENT_QUOTES, 'UTF-8') . '"' . $style;
1372
1373 if ($type === 'external_video') {
1374 $url = (string)($row['embed_url'] ?? $row['external_url'] ?? '');
1375 if ($url === '') return '';
1376 $url = $this->external_video_option_url($url, $autoplay, $muted, $loop);
1377 $loading = $autoplay ? 'eager' : 'lazy';
1378 if ($this->is_youtube_media($row, $url)) {
1379 return '<figure' . $figure_attr . '>' . $this->render_youtube_consent_block($url, (string)($row['title'] ?? $row['alt'] ?? $row['file_name'] ?? 'Video'), $loading) . $caption_html . '</figure>';
1380 }
1381 return '<figure' . $figure_attr . '><iframe class="dbx-content-video-player" src="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" title="' . $title . '" loading="' . $loading . '" allowfullscreen></iframe>' . $caption_html . '</figure>';
1382 }
1383
1384 $url = 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $id;
1385 $poster = !empty($row['thumb_file_path']) ? $url . '&dbx_thumb=1' : '';
1386 $poster_attr = $poster !== '' ? ' poster="' . htmlspecialchars($poster, ENT_QUOTES, 'UTF-8') . '"' : '';
1387 $mime_attr = trim((string)($row['mime'] ?? '')) !== '' ? ' type="' . htmlspecialchars((string)$row['mime'], ENT_QUOTES, 'UTF-8') . '"' : '';
1388 $autoplay_attr = $autoplay ? ' autoplay' : '';
1389 $loop_attr = $loop ? ' loop' : '';
1390 $muted_attr = $muted ? ' muted' : '';
1391 return '<figure' . $figure_attr . '><video class="dbx-content-video-player" controls preload="metadata" playsinline' . $autoplay_attr . $loop_attr . $muted_attr . $poster_attr . '><source src="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '"' . $mime_attr . '></video>' . $caption_html . '</figure>';
1392 }
1393
1394 private function inline_video_options_from_html($source_attrs, $source_html) {
1395 $style = '';
1396 if (preg_match('/\sstyle=(["\'])(.*?)\1/i', (string)$source_attrs, $m)) {
1397 $style = $this->safe_inline_video_style($m[2]);
1398 }
1399
1400 $width = $this->attr_value_from_html($source_attrs, 'data-cms-video-width');
1401 $height = $this->attr_value_from_html($source_attrs, 'data-cms-video-height');
1402 $align_raw = $this->attr_value_from_html($source_attrs, 'data-cms-video-align');
1403 if ($width === '') $width = $this->attr_value_from_html($source_attrs, 'width');
1404 if ($height === '') $height = $this->attr_value_from_html($source_attrs, 'height');
1405
1406 if ((string)$source_html !== '' && preg_match('/<(?:img|video|iframe)\b([^>]*)>/i', (string)$source_html, $media_match)) {
1407 $media_attrs = $media_match[1];
1408 if ($width === '') $width = $this->attr_value_from_html($media_attrs, 'data-cms-video-width');
1409 if ($height === '') $height = $this->attr_value_from_html($media_attrs, 'data-cms-video-height');
1410 if ($width === '') $width = $this->attr_value_from_html($media_attrs, 'width');
1411 if ($height === '') $height = $this->attr_value_from_html($media_attrs, 'height');
1412 if ($align_raw === '') $align_raw = $this->attr_value_from_html($media_attrs, 'data-cms-video-align');
1413 if ($style === '' && preg_match('/\sstyle=(["\'])(.*?)\1/i', $media_attrs, $style_match)) {
1414 $style = $this->safe_inline_video_style($style_match[2]);
1415 }
1416 }
1417
1418 $width = $this->css_size_value($width);
1419 $height = $this->css_size_value($height);
1420 $style_parts = $this->style_to_map($style);
1421 if ($width !== '') $style_parts['width'] = $width;
1422 if ($height !== '') $style_parts['height'] = $height;
1423 $align = $this->inline_video_align_value($align_raw, $style_parts);
1424 if ($align === 'center') {
1425 unset($style_parts['float']);
1426 $style_parts['margin-left'] = 'auto';
1427 $style_parts['margin-right'] = 'auto';
1428 } elseif ($align === 'right') {
1429 unset($style_parts['float']);
1430 $style_parts['margin-left'] = 'auto';
1431 $style_parts['margin-right'] = '0px';
1432 } elseif ($align_raw !== '') {
1433 unset($style_parts['float'], $style_parts['margin-left'], $style_parts['margin-right']);
1434 }
1435
1436 $autoplay_raw = $this->attr_value_from_html($source_attrs, 'data-cms-video-autoplay');
1437 if ($autoplay_raw === '' && (string)$source_html !== '' && preg_match('/<(?:img|video|iframe)\b([^>]*)>/i', (string)$source_html, $media_match)) {
1438 $autoplay_raw = $this->attr_value_from_html($media_match[1], 'data-cms-video-autoplay');
1439 }
1440 $muted_raw = $this->attr_value_from_html($source_attrs, 'data-cms-video-muted');
1441 if ($muted_raw === '' && (string)$source_html !== '' && preg_match('/<(?:img|video|iframe)\b([^>]*)>/i', (string)$source_html, $media_match)) {
1442 $muted_raw = $this->attr_value_from_html($media_match[1], 'data-cms-video-muted');
1443 }
1444
1445 $loop_raw = $this->attr_value_from_html($source_attrs, 'data-cms-video-loop');
1446 if ($loop_raw === '' && (string)$source_html !== '' && preg_match('/<(?:img|video|iframe)\b([^>]*)>/i', (string)$source_html, $media_match)) {
1447 $loop_raw = $this->attr_value_from_html($media_match[1], 'data-cms-video-loop');
1448 }
1449
1450 return array(
1451 'style' => $this->style_map_to_string($style_parts),
1452 'autoplay' => preg_match('/^(1|true|yes|ja|on)$/i', trim((string)$autoplay_raw)) === 1,
1453 'loop' => preg_match('/^(1|true|yes|ja|on)$/i', trim((string)$loop_raw)) === 1,
1454 'muted' => preg_match('/^(1|true|yes|ja|on)$/i', trim((string)$muted_raw)) === 1,
1455 'align' => $align,
1456 );
1457 }
1458
1459 private function inline_video_align_value($value, array $style = array()) {
1460 $value = strtolower(trim((string)$value));
1461 if (in_array($value, array('left', 'center', 'right'), true)) return $value;
1462 $margin_left = strtolower(trim((string)($style['margin-left'] ?? '')));
1463 $margin_right = strtolower(trim((string)($style['margin-right'] ?? '')));
1464 if ($margin_left === 'auto' && $margin_right === 'auto') return 'center';
1465 if ($margin_left === 'auto') return 'right';
1466 return 'left';
1467 }
1468
1469 private function attr_value_from_html($attrs, $name) {
1470 $name = preg_quote((string)$name, '/');
1471 if (preg_match('/\s' . $name . '\s*=\s*(["\'])(.*?)\1/i', (string)$attrs, $m)) return trim((string)$m[2]);
1472 if (preg_match('/\s' . $name . '\s*=\s*([^\s>]+)/i', (string)$attrs, $m)) return trim((string)$m[1], "\"' ");
1473 return '';
1474 }
1475
1476 private function css_size_value($value) {
1477 $value = trim((string)$value);
1478 if ($value === '') return '';
1479 if (preg_match('/^[0-9]+(?:\.[0-9]+)?$/', $value)) return $value . 'px';
1480 if (preg_match('/^[0-9]+(?:\.[0-9]+)?(?:px|%|vw|vh|rem|em)$/i', $value)) return $value;
1481 if (strtolower($value) === 'auto') return 'auto';
1482 return '';
1483 }
1484
1485 private function safe_inline_video_style($style) {
1486 $out = array();
1487 foreach ($this->style_to_map($style) as $key => $value) {
1488 if (!in_array($key, array('width', 'height', 'max-width', 'float', 'margin-left', 'margin-right', 'display'), true)) continue;
1489 if (in_array($key, array('width', 'height', 'max-width', 'margin-left', 'margin-right'), true)) {
1490 $value = $this->css_size_value($value);
1491 if ($value === '') continue;
1492 } elseif ($key === 'float') {
1493 if (!in_array(strtolower($value), array('left', 'right', 'none'), true)) continue;
1494 $value = strtolower($value);
1495 } elseif ($key === 'display') {
1496 if (!in_array(strtolower($value), array('block', 'inline-block'), true)) continue;
1497 $value = strtolower($value);
1498 }
1499 $out[$key] = $value;
1500 }
1501 return $this->style_map_to_string($out);
1502 }
1503
1504 private function style_to_map($style) {
1505 $out = array();
1506 foreach (explode(';', (string)$style) as $part) {
1507 if (strpos($part, ':') === false) continue;
1508 list($key, $value) = array_map('trim', explode(':', $part, 2));
1509 $key = strtolower($key);
1510 if ($key === '' || $value === '') continue;
1511 $out[$key] = $value;
1512 }
1513 return $out;
1514 }
1515
1516 private function style_map_to_string(array $style) {
1517 $out = array();
1518 foreach ($style as $key => $value) {
1519 if ((string)$value === '') continue;
1520 $out[] = $key . ': ' . $value;
1521 }
1522 return implode('; ', $out);
1523 }
1524
1525 private function external_video_option_url($url, $autoplay = false, $muted = false, $loop = false) {
1526 $url = trim((string)$url);
1527 if ($url === '') return '';
1528 $join = (strpos($url, '?') === false) ? '?' : '&';
1529 $params = array('playsinline' => '1', 'rel' => '0');
1530 if ($autoplay) $params['autoplay'] = '1';
1531 $params['mute'] = $muted ? '1' : '0';
1532 if ($loop) {
1533 $params['loop'] = '1';
1534 $video_id = $this->youtube_video_id_from_url($url);
1535 if ($video_id !== '') $params['playlist'] = $video_id;
1536 }
1537 foreach ($params as $key => $value) {
1538 if (preg_match('/(?:[?&])' . preg_quote($key, '/') . '=/i', $url)) continue;
1539 $url .= $join . rawurlencode($key) . '=' . rawurlencode($value);
1540 $join = '&';
1541 }
1542 return $url;
1543 }
1544
1545 private function youtube_video_id_from_url($url) {
1546 $url = trim((string)$url);
1547 if ($url === '') return '';
1548 if (preg_match('~(?:embed/|v=|youtu\.be/)([A-Za-z0-9_-]{11})~i', $url, $m)) {
1549 return (string)$m[1];
1550 }
1551 return '';
1552 }
1553
1554 private function is_youtube_media(array $row, $url) {
1555 $provider = strtolower(trim((string)($row['provider'] ?? '')));
1556 if ($provider === 'youtube') return true;
1557 $url = trim((string)$url);
1558 if ($url !== '' && preg_match('~(?:youtube\.com|youtu\.be)~i', $url)) return true;
1559 $embed = trim((string)($row['embed_url'] ?? $row['external_url'] ?? ''));
1560 if ($embed !== '' && preg_match('~(?:youtube\.com|youtu\.be)~i', $embed)) return true;
1561 $provider_id = trim((string)($row['provider_id'] ?? ''));
1562 return $provider_id !== '' && preg_match('~^[A-Za-z0-9_-]{11}$~', $provider_id) && $provider !== 'vimeo';
1563 }
1564
1565 private function render_youtube_consent_block($embed_url, $title, $loading = 'lazy') {
1566 $embed_url = trim((string)$embed_url);
1567 if ($embed_url === '') return '';
1568 $title = htmlspecialchars(trim((string)$title), ENT_QUOTES, 'UTF-8');
1569 $loading = ($loading === 'eager') ? 'eager' : 'lazy';
1570 $video_id = $this->youtube_video_id_from_url($embed_url);
1571 $thumb = $video_id !== '' ? 'https://img.youtube.com/vi/' . $video_id . '/hqdefault.jpg' : '';
1572 $thumb_html = $thumb !== ''
1573 ? '<img class="dbx-youtube-consent-thumb" src="' . htmlspecialchars($thumb, ENT_QUOTES, 'UTF-8') . '" alt="" loading="lazy">'
1574 : '';
1575 return '<div class="dbx-youtube-consent-placeholder"'
1576 . ' data-youtube-embed-url="' . htmlspecialchars($embed_url, ENT_QUOTES, 'UTF-8') . '"'
1577 . ' data-youtube-title="' . $title . '"'
1578 . ' data-youtube-loading="' . $loading . '">'
1579 . $thumb_html
1580 . '<button type="button" class="dbx-youtube-consent-play" aria-label="Video abspielen">'
1581 . '<i class="bi bi-play-fill"></i></button>'
1582 . '</div>';
1583 }
1584
1585 private function render_youtube_hero_figure(array $row, $url, array $settings) {
1586 $url = $this->external_video_option_url($url, false, false, false);
1587 $title = (string)($row['title'] ?? $row['alt'] ?? $row['file_name'] ?? 'Video');
1588 $caption = trim((string)($row['caption'] ?? ''));
1589 $caption_html = $caption !== '' ? '<figcaption>' . htmlspecialchars($caption, ENT_QUOTES, 'UTF-8') . '</figcaption>' : '';
1590 $hero_variant = preg_replace('/[^a-z0-9_-]+/i', '-', strtolower((string)($settings['hero_variant'] ?? 'original')));
1591 $hero_scroll = preg_replace('/[^a-z0-9_-]+/i', '-', strtolower((string)($settings['hero_scroll_layer'] ?? 'under')));
1592 $hero_classes = array(
1593 'dbx-media',
1594 'dbx-media-video',
1595 'dbx-media-external-video',
1596 'dbx-media-hero',
1597 'dbx-media-hero-' . ($hero_variant !== '' ? $hero_variant : 'original'),
1598 $this->is_enabled_value($settings['hero_sticky'] ?? '0') ? 'dbx-media-hero-sticky' : '',
1599 'dbx-media-hero-scroll-' . ($hero_scroll !== '' ? $hero_scroll : 'under'),
1600 );
1601 $style = $this->css_custom_properties(array(
1602 'cms-hero-margin-top' => $this->css_length_value($settings['hero_margin_top'] ?? '0', '0'),
1603 'cms-hero-height' => $this->css_length_value($settings['hero_height'] ?? 'auto', 'auto'),
1604 ));
1605 return '<figure class="' . htmlspecialchars(implode(' ', array_filter($hero_classes)), ENT_QUOTES, 'UTF-8') . '"'
1606 . ($style !== '' ? ' style="' . $style . '"' : '')
1607 . '>' . $this->render_youtube_consent_block($url, $title, 'lazy') . $caption_html . '</figure>';
1608 }
1609
1610 private function render_media_merge($db, $tpl, $cid, array $slots, array $rec, array $settings = array()) {
1611 $merge = array(
1612 'media:hero' => '',
1613 'media:gallery' => '',
1614 'media:header' => '',
1615 'media:teaser' => '',
1616 'media:footer' => '',
1617 'media_hero' => '',
1618 'media_gallery' => '',
1619 'media_header' => '',
1620 'media_teaser' => '',
1621 'media_footer' => '',
1622 );
1623
1624 $usage_rows = $db->select(
1625 $this->dd_media_usage,
1626 dbxContentMediaUsageScope::withLanguage('content_id = ' . (int)$cid . ' AND active = 1'),
1627 '*',
1628 'slot,sorter,id',
1629 'ASC',
1630 '',
1631 0,
1632 0,
1633 0
1634 );
1635 $rows = array();
1636 if (is_array($usage_rows) && !empty($usage_rows)) {
1637 $mediaIds = array_values(array_filter(array_unique(array_map(
1638 static fn($usage) => (int)($usage['media_id'] ?? 0),
1639 $usage_rows
1640 ))));
1641 $mediaById = array();
1642 if ($mediaIds) {
1643 $mediaRows = $db->select(
1644 $this->dd_media,
1645 'id IN (' . implode(',', $mediaIds) . ') AND active = 1',
1646 '*',
1647 '',
1648 'ASC',
1649 '',
1650 0,
1651 0,
1652 0
1653 );
1654 foreach ((is_array($mediaRows) ? $mediaRows : array()) as $mediaRow) {
1655 $mediaById[(int)($mediaRow['id'] ?? 0)] = $mediaRow;
1656 }
1657 }
1658 foreach ($usage_rows as $usage) {
1659 if (!is_array($usage)) continue;
1660 $media_id = (int)($usage['media_id'] ?? 0);
1661 if ($media_id <= 0) continue;
1662 $row = $mediaById[$media_id] ?? array();
1663 if (!$row) continue;
1664 $row['slot'] = (string)($usage['slot'] ?? 'gallery');
1665 $row['sorter'] = (string)($usage['sorter'] ?? '');
1666 if (!empty($usage['template'])) $row['template'] = $usage['template'];
1667 if (!empty($usage['caption'])) $row['caption'] = $usage['caption'];
1668 $row['usage_id'] = (int)($usage['id'] ?? 0);
1669 $rows[] = $row;
1670 }
1671 } else {
1672 $rows = $db->select($this->dd_media, 'content_id = ' . (int)$cid . ' AND active = 1', '*', 'slot,sorter,title,id', 'ASC', '', 0, 0, 0);
1673 }
1674 if (!is_array($rows)) return $merge;
1675
1676 $by_slot = array();
1677 foreach ($rows as $row) {
1678 if (!is_array($row)) continue;
1679 if (!$this->media_file_exists($row)) continue;
1680 $slot = trim((string)($row['slot'] ?? 'gallery')) ?: 'gallery';
1681 if (!isset($by_slot[$slot])) $by_slot[$slot] = array();
1682 $by_slot[$slot][] = $row;
1683 }
1684
1685 if (!$settings) $settings = $this->content_settings($db, $rec);
1686 $needed = array_keys($slots['media'] ?? array());
1687 foreach (array('hero', 'gallery', 'header', 'teaser', 'footer') as $slot) {
1688 if (!in_array($slot, $needed, true)) continue;
1689 $rows_for_slot = $by_slot[$slot] ?? array();
1690 if ($slot === 'hero') {
1691 if ($this->is_no_hero_value($settings['hero_template'] ?? '') || $this->is_no_hero_value($settings['hero_image_id'] ?? '')) continue;
1692 $rows_for_slot = $this->one_hero_row($db, $rows_for_slot, $settings['hero_image_id']);
1693 }
1694 if (empty($rows_for_slot)) continue;
1695 $html = $this->render_media_slot($tpl, $rows_for_slot, $slot, $settings);
1696 $merge['media:' . $slot] = $html;
1697 $merge['media_' . $slot] = $html;
1698 }
1699 return $merge;
1700 }
1701
1702 private function one_hero_row($db, array $rows, $hero_id) {
1703 $hero_id = (int)$hero_id;
1704 if ($hero_id > 0) {
1705 $row = $db->select1($this->dd_media, $hero_id);
1706 if (is_array($row) && (int)($row['active'] ?? 0) === 1 && $this->media_file_exists($row)) return array($row);
1707 }
1708 return isset($rows[0]) ? array($rows[0]) : array();
1709 }
1710
1711 private function render_media_slot($tpl, array $rows, $slot, array $settings) {
1712 if (!$rows) return '';
1713 $html = '';
1714 foreach ($rows as $row) {
1715 $mime = (string)($row['mime'] ?? '');
1716 $type = ((string)($row['media_type'] ?? '') === 'external_video')
1717 ? 'external_video'
1718 : ((strpos($mime, 'video/') === 0) ? 'video' : ((strpos($mime, 'image/') === 0 || preg_match('/\.(jpg|jpeg|png|gif|webp|svg)$/i', (string)($row['file_name'] ?? ''))) ? 'image' : 'file'));
1719 $template = trim((string)($row['template'] ?? ''));
1720 if ($slot === 'hero' && !empty($settings['hero_template'])) $template = $settings['hero_template'];
1721 if ($slot === 'gallery' && !empty($settings['gallery_template'])) {
1722 $template = $settings['gallery_template'];
1723 if ($type === 'image' && $template === 'video-gallery') $template = 'image-gallery';
1724 if (($type === 'video' || $type === 'external_video') && in_array($template, array('image-gallery', 'carousel3', 'cols3'), true)) $template = $type . '-gallery';
1725 if ($type === 'file' && in_array($template, array('image-gallery', 'video-gallery', 'carousel3', 'cols3'), true)) $template = 'file-gallery';
1726 }
1727 if ($template === '' || strtolower($template) === 'parent') $template = $type . '-' . ($slot === 'gallery' ? 'gallery' : $slot);
1728 if (!$this->media_template_exists($template)) $template = $type . '-' . ($slot === 'gallery' ? 'gallery' : $slot);
1729 if (!$this->media_template_exists($template)) $template = 'file-' . ($slot === 'gallery' ? 'gallery' : $slot);
1730
1731 $url = $type === 'external_video'
1732 ? (string)($row['embed_url'] ?? $row['external_url'] ?? '')
1733 : 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . (int)($row['id'] ?? 0);
1734 if ($type === 'external_video' && $slot === 'hero' && $this->is_youtube_media($row, $url)) {
1735 $html .= $this->render_youtube_hero_figure($row, $url, $settings);
1736 continue;
1737 }
1738 if ($type === 'external_video') {
1739 $thumb_url = $this->external_video_thumb_url($row);
1740 $poster_url = $thumb_url;
1741 } else {
1742 $thumb_url = !empty($row['thumb_file_path']) ? $url . '&dbx_thumb=1' : $url;
1743 $poster_url = !empty($row['thumb_file_path']) ? $url . '&dbx_thumb=1' : '';
1744 if ($slot === 'gallery'
1745 && $type === 'image'
1746 && strtolower(trim((string)($settings['gallery_overflow'] ?? ''))) === 'tutorial') {
1747 $thumb_url = $url;
1748 }
1749 }
1750 $data = array(
1751 'id' => (string)($row['id'] ?? ''),
1752 'url' => htmlspecialchars($url, ENT_QUOTES, 'UTF-8'),
1753 'thumb_url' => htmlspecialchars($thumb_url, ENT_QUOTES, 'UTF-8'),
1754 'poster_url' => htmlspecialchars($poster_url, ENT_QUOTES, 'UTF-8'),
1755 'media_type' => htmlspecialchars($type, ENT_QUOTES, 'UTF-8'),
1756 'title' => htmlspecialchars((string)($row['title'] ?? ''), ENT_QUOTES, 'UTF-8'),
1757 'alt' => htmlspecialchars((string)($row['alt'] ?? $row['title'] ?? ''), ENT_QUOTES, 'UTF-8'),
1758 'caption' => htmlspecialchars((string)($row['caption'] ?? ''), ENT_QUOTES, 'UTF-8'),
1759 'slot' => htmlspecialchars($slot, ENT_QUOTES, 'UTF-8'),
1760 'mime' => htmlspecialchars($mime, ENT_QUOTES, 'UTF-8'),
1761 'hero_margin_top' => htmlspecialchars($this->css_length_value($settings['hero_margin_top'] ?? '0', '0'), ENT_QUOTES, 'UTF-8'),
1762 'hero_height' => htmlspecialchars($this->css_length_value($settings['hero_height'] ?? 'auto', 'auto'), ENT_QUOTES, 'UTF-8'),
1763 'hero_variant' => htmlspecialchars((string)($settings['hero_variant'] ?? 'original'), ENT_QUOTES, 'UTF-8'),
1764 'hero_sticky_class' => $this->is_enabled_value($settings['hero_sticky'] ?? '0') ? 'dbx-media-hero-sticky' : '',
1765 'hero_scroll_class' => 'dbx-media-hero-scroll-' . htmlspecialchars((string)($settings['hero_scroll_layer'] ?? 'under'), ENT_QUOTES, 'UTF-8'),
1766 );
1767 $width = max(0, (int)($row['width'] ?? 0));
1768 $height = max(0, (int)($row['height'] ?? 0));
1769 $thumbWidth = max(0, (int)($row['thumb_width'] ?? 0));
1770 $thumbHeight = max(0, (int)($row['thumb_height'] ?? 0));
1771 if ($thumbWidth <= 0 || $thumbHeight <= 0) {
1772 $thumbWidth = $width;
1773 $thumbHeight = $height;
1774 }
1775 $visibleCount = max(1, min(12, (int)($settings['gallery_visible_count'] ?? 3)));
1776 $data['image_dimensions'] = ($width > 0 && $height > 0)
1777 ? ' width="' . $width . '" height="' . $height . '"'
1778 : '';
1779 $data['thumb_dimensions'] = ($thumbWidth > 0 && $thumbHeight > 0)
1780 ? ' width="' . $thumbWidth . '" height="' . $thumbHeight . '"'
1781 : '';
1782 $data['image_sizes'] = $slot === 'gallery'
1783 ? '(max-width: 767px) 100vw, ' . (int)ceil(100 / $visibleCount) . 'vw'
1784 : '100vw';
1785 $html .= $tpl->get_tpl('dbxContent|media-' . $template, $data);
1786 }
1787 return $html;
1788 }
1789
1790 private function external_video_thumb_url(array $row) {
1791 $provider = strtolower(trim((string)($row['provider'] ?? '')));
1792 $provider_id = trim((string)($row['provider_id'] ?? ''));
1793 if ($provider === 'youtube' && preg_match('~^[A-Za-z0-9_-]{11}$~', $provider_id)) {
1794 return 'https://img.youtube.com/vi/' . $provider_id . '/hqdefault.jpg';
1795 }
1796 return '';
1797 }
1798
1799 private function media_template_exists($template) {
1800 $template = preg_replace('/[^a-z0-9_-]+/i', '', (string)$template);
1801 if ($template === '') return false;
1802 $path = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/dbxContent/tpl/htm/media-' . strtolower($template) . '.htm');
1803 return is_file($path);
1804 }
1805
1806 private function media_file_exists(array $row) {
1807 if (strtolower((string)($row['storage_type'] ?? '')) === 'external') {
1808 return trim((string)($row['embed_url'] ?? $row['external_url'] ?? '')) !== '';
1809 }
1810 $rel = ltrim(str_replace('\\', '/', (string)($row['file_path'] ?? '')), '/');
1811 if ($rel === '' || strpos($rel, '..') !== false) return false;
1812
1813 $root = dbx()->os_path(rtrim(dbx()->get_file_dir(), '/\\') . '/');
1814 $file = dbx()->os_path($root . $rel);
1815
1816 $base = realpath($root);
1817 $real = realpath($file);
1818 return $base && $real && strpos($real, $base) === 0 && is_file($real) && is_readable($real);
1819 }
1820
1821 private function replace_content_markers($html, array $merge) {
1822 foreach ($merge as $key => $value) {
1823 if (strpos($key, ':') !== false) {
1824 $html = str_replace('{' . $key . '}', $value, $html);
1825 }
1826 }
1827 return $html;
1828 }
1829
1830 private function render_inline($tpl, $slot, $value, $obj_key = '') {
1831 if (!$this->html_has_visible_content($value)) return '';
1832 $file = 'i-' . $slot;
1833 $data_key = $obj_key !== '' ? $obj_key : $slot;
1834 $path = dbx()->os_path(dbx()->get_base_dir() . 'dbx/modules/dbxContent/tpl/htm/' . strtolower($file) . '.htm');
1835 if (!is_file($path)) return $value;
1836 $html = $tpl->get_tpl('dbxContent|' . $file, array('obj:' . $data_key => $value, $data_key => $value));
1837 if (strpos($html, 'not found') !== false || trim($html) === '') return $value;
1838 $html = str_replace('{obj:' . $data_key . '}', $value, $html);
1839 return $html;
1840 }
1841
1842 private function html_has_visible_content($html) {
1843 $html = $this->strip_dbx_markers((string)$html);
1844 if (trim($html) === '') return false;
1845 if (stripos($html, '[modul=') !== false) return true;
1846 if (preg_match('/<(img|video|iframe|source|object|embed|table|ul|ol|li|figure)\b/i', $html)) return true;
1847 $text = preg_replace('/<!--[\s\S]*?-->/u', '', $html);
1848 $text = preg_replace('/<br\s*\/?>/iu', '', $text);
1849 $text = strip_tags($text);
1850 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1851 $text = str_replace("\xC2\xA0", ' ', $text);
1852 return trim($text) !== '';
1853 }
1854
1855 private function render_title_slot($tpl, $template_html, $title) {
1856 $title = htmlspecialchars((string)$title, ENT_QUOTES, 'UTF-8');
1857 if (preg_match('/<h[1-6]\b[^>]*>[^<]*\{cms:title\}/i', (string)$template_html)) {
1858 return $title;
1859 }
1860 return $this->render_inline($tpl, 'title', $title);
1861 }
1862
1863 private function css_class_token($prefix, $value) {
1864 $prefix = preg_replace('/[^a-z0-9_-]+/i', '-', strtolower((string)$prefix));
1865 $value = preg_replace('/[^a-z0-9_-]+/i', '-', strtolower((string)$value));
1866 $value = trim($value, '-');
1867 if ($value === '') $value = 'default';
1868 return trim($prefix . '-' . $value, '-');
1869 }
1870
1871 private function css_style_value($value, $fallback = '') {
1872 $value = trim((string)$value);
1873 if ($value === '' || strtolower($value) === 'parent') $value = (string)$fallback;
1874 $value = preg_replace('/[;"<>]+/', '', $value);
1875 $value = preg_replace('/[^a-z0-9\s#.,%()+\-\/_]+/i', '', $value);
1876 return trim($value);
1877 }
1878
1879 private function css_length_value($value, $fallback = '') {
1880 $value = $this->css_style_value($value, $fallback);
1881 if ($value === '') return '';
1882 if (preg_match('/^-?(?:[0-9]+|[0-9]*\.[0-9]+)$/', $value)) {
1883 return $value === '0' ? '0' : $value . 'px';
1884 }
1885 return $value;
1886 }
1887
1888 private function hero_content_custom_properties($height) {
1889 $scale = $this->hero_content_scale($height);
1890 return array(
1891 'cms-hero-content-scale' => $this->css_number($scale),
1892 'cms-hero-content-size' => $this->css_px(18 * $scale),
1893 'cms-hero-title-size' => $this->css_px(46 * $scale),
1894 'cms-hero-subtitle-size' => $this->css_px(30 * $scale),
1895 'cms-hero-content-padding' => $this->css_px(10 * $scale),
1896 'cms-hero-content-offset' => $this->css_px(34 * $scale),
1897 );
1898 }
1899
1900 private function hero_content_scale($height) {
1901 $px = $this->css_length_to_px($height);
1902 if ($px <= 0) return 1.0;
1903
1904 $scale = $px / 420.0;
1905 if ($scale < 0.72) $scale = 0.72;
1906 if ($scale > 1.85) $scale = 1.85;
1907 return $scale;
1908 }
1909
1910 private function css_length_to_px($value) {
1911 $value = strtolower(trim($this->css_length_value($value, 'auto')));
1912 if ($value === '' || $value === 'auto' || $value === 'parent') return 0.0;
1913
1914 if (preg_match('/^([0-9]+(?:\.[0-9]+)?)px$/', $value, $m)) return (float)$m[1];
1915 if (preg_match('/^([0-9]+(?:\.[0-9]+)?)$/', $value, $m)) return (float)$m[1];
1916 if (preg_match('/^([0-9]+(?:\.[0-9]+)?)(rem|em)$/', $value, $m)) return (float)$m[1] * 16.0;
1917 if (preg_match('/^([0-9]+(?:\.[0-9]+)?)(vh|svh|lvh|dvh)$/', $value, $m)) return (float)$m[1] * 7.2;
1918 if (preg_match('/^([0-9]+(?:\.[0-9]+)?)vw$/', $value, $m)) return (float)$m[1] * 12.8;
1919
1920 return 0.0;
1921 }
1922
1923 private function css_px($value) {
1924 return $this->css_number($value) . 'px';
1925 }
1926
1927 private function css_number($value) {
1928 $value = (float)$value;
1929 $out = number_format($value, 3, '.', '');
1930 return rtrim(rtrim($out, '0'), '.');
1931 }
1932
1933 private function css_custom_properties(array $props) {
1934 $out = array();
1935 foreach ($props as $name => $value) {
1936 $name = preg_replace('/[^a-z0-9_-]+/i', '-', (string)$name);
1937 $value = trim((string)$value);
1938 if ($name === '' || $value === '') continue;
1939 $out[] = '--' . strtolower($name) . ':' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1940 }
1941 return implode(';', $out);
1942 }
1943
1944 private function gallery_aspect_ratio($size) {
1945 if (preg_match('/^\s*([0-9]{1,5})\s*x\s*([0-9]{1,5})\s*$/i', (string)$size, $m)) {
1946 $w = max(1, (int)$m[1]);
1947 $h = max(1, (int)$m[2]);
1948 return $w . ' / ' . $h;
1949 }
1950 return '4 / 3';
1951 }
1952
1953 private function gallery_data_dbx_attrs(array $settings, bool $has_slot, bool $has_media): string {
1954 if (!$has_slot || !$has_media) {
1955 return '';
1956 }
1957
1958 $parts = array(
1959 'lib=gallery',
1960 'img-count=' . max(1, min(12, (int)($settings['gallery_visible_count'] ?? 3))),
1961 'img-size=' . trim((string)($settings['gallery_image_size'] ?? 'original')),
1962 'lightbox-width=' . $this->css_length_value($settings['gallery_lightbox_width'] ?? '100vw', '100vw'),
1963 'overflow=' . trim((string)($settings['gallery_overflow'] ?? 'grid')),
1964 'click=' . trim((string)($settings['gallery_click_behavior'] ?? 'lightbox')),
1965 );
1966
1967 return ' data-dbx="' . htmlspecialchars(implode('|', $parts), ENT_QUOTES, 'UTF-8') . '"';
1968 }
1969
1970 private function seoExcerptFromContent($html, $max = 160) {
1971 $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$html), ENT_QUOTES, 'UTF-8')));
1972 if ($text === '') return '';
1973
1974 $len = function_exists('mb_strlen') ? mb_strlen($text, 'UTF-8') : strlen($text);
1975 if ($len <= $max) return $text;
1976
1977 $excerpt = function_exists('mb_substr') ? mb_substr($text, 0, $max, 'UTF-8') : substr($text, 0, $max);
1978 if (function_exists('mb_strrpos')) {
1979 $cut = mb_strrpos($excerpt, ' ', 0, 'UTF-8');
1980 if ($cut !== false && $cut > 40) {
1981 $excerpt = mb_substr($excerpt, 0, $cut, 'UTF-8');
1982 }
1983 } elseif (($cut = strrpos($excerpt, ' ')) !== false && $cut > 40) {
1984 $excerpt = substr($excerpt, 0, $cut);
1985 }
1986
1987 return rtrim($excerpt, ".,;:- \t") . '…';
1988 }
1989
1991 private function seoAbsoluteUrl(string $path): string {
1992 return rtrim((string)dbx()->get_base_url(), '/') . '/' . ltrim($path, '/');
1993 }
1994
2002 private function seoCanonicalUrl($permalink, bool $isHomePage = false) {
2003 if ($isHomePage) {
2004 return rtrim((string)dbx()->get_base_url(), '/') . '/';
2005 }
2006
2007 $permalink = trim((string)$permalink);
2008 if ($permalink !== '' && preg_match('/^https?:\/\//i', $permalink)) {
2009 return $permalink;
2010 }
2011 return $this->seoAbsoluteUrl($permalink);
2012 }
2013
2014 private function seoOgImageUrl($db, array $rec) {
2015 $seo_image_id = (int)($rec['seo_image_id'] ?? 0);
2016 if ($seo_image_id > 0) {
2017 $url = $this->seoOgImageFromMediaId($db, $seo_image_id);
2018 if ($url !== '') return $url;
2019 }
2020
2021 $settings = $this->content_settings($db, $rec);
2022 $hero_id = (int)($settings['hero_image_id'] ?? 0);
2023 if ($hero_id <= 0) {
2024 $cid = (int)($rec['id'] ?? 0);
2025 if ($cid > 0) {
2026 $usage_rows = $db->select(
2027 $this->dd_media_usage,
2028 dbxContentMediaUsageScope::withLanguage('content_id = ' . $cid . ' AND active = 1 AND slot = \'hero\''),
2029 'media_id',
2030 'sorter,id',
2031 'ASC',
2032 '',
2033 1,
2034 0,
2035 0
2036 );
2037 if (is_array($usage_rows) && !empty($usage_rows[0]['media_id'])) {
2038 $hero_id = (int)$usage_rows[0]['media_id'];
2039 }
2040 }
2041 }
2042 if ($hero_id <= 0) return '';
2043
2044 $row = $db->select1($this->dd_media, $hero_id);
2045 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1 || !$this->media_file_exists($row)) {
2046 return '';
2047 }
2048
2049 $mime = (string)($row['mime'] ?? '');
2050 $file_name = (string)($row['file_name'] ?? $row['file_path'] ?? '');
2051 if (strpos($mime, 'image/') !== 0 && !preg_match('/\.(jpg|jpeg|png|gif|webp|svg)$/i', $file_name)) {
2052 return '';
2053 }
2054
2055 return $this->seoAbsoluteUrl('index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $hero_id);
2056 }
2057
2058 private function seoOgImageFromMediaId($db, int $media_id) {
2059 $media_id = (int)$media_id;
2060 if ($media_id <= 0) return '';
2061
2062 $row = $db->select1($this->dd_media, $media_id);
2063 if (!is_array($row) || (int)($row['active'] ?? 0) !== 1 || !$this->media_file_exists($row)) {
2064 return '';
2065 }
2066
2067 $mime = (string)($row['mime'] ?? '');
2068 $file_name = (string)($row['file_name'] ?? $row['file_path'] ?? '');
2069 if (strpos($mime, 'image/') !== 0 && !preg_match('/\.(jpg|jpeg|png|gif|webp|svg)$/i', $file_name)) {
2070 return '';
2071 }
2072
2073 return $this->seoAbsoluteUrl('index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $media_id);
2074 }
2075
2076 private function seoHreflangBlock($db, array $rec, string $currentLng): string {
2077 $lngUid = trim((string)($rec['lng_uid'] ?? ''));
2078 if ($lngUid === '' || !function_exists('dbx_accessible_lngs')) {
2079 return '';
2080 }
2081
2082 $lngs = dbx_accessible_lngs();
2083 if (!is_array($lngs) || count($lngs) < 2) {
2084 return '';
2085 }
2086
2087 $alternates = array();
2088 $escapedUid = str_replace("'", "''", $lngUid);
2089 $isHomeGroup = dbxContentHome::resolveCid($currentLng) === (int)($rec['id'] ?? 0);
2090 $masterLng = dbxContentLngSync::masterLng();
2091
2092 foreach ($lngs as $lng) {
2093 $lng = strtolower(trim((string)$lng));
2094 if ($lng === '' || !preg_match('/^[a-z]{2,3}$/', $lng)) continue;
2095
2096 $permalink = $this->seoHreflangPermalink($db, $lng, $lngUid, $escapedUid, $rec, $currentLng);
2097 if ($permalink === '') continue;
2098
2099 $alternates[] = array(
2100 'lng' => $lng,
2101 'url' => $this->seoCanonicalUrl(
2102 $permalink,
2103 $isHomeGroup && $lng === $masterLng
2104 ),
2105 );
2106 }
2107
2108 if (count($alternates) < 2) {
2109 return '';
2110 }
2111
2112 $lines = array();
2113 $defaultLng = strtolower(trim((string)dbx()->get_config('dbx', 'default_lng', 'de')));
2114 if ($defaultLng === '' || $defaultLng === 'undef') $defaultLng = 'de';
2115
2116 foreach ($alternates as $alt) {
2117 $lines[] = '<link rel="alternate" hreflang="' . htmlspecialchars((string)$alt['lng'], ENT_QUOTES, 'UTF-8') . '" href="' . htmlspecialchars((string)$alt['url'], ENT_QUOTES, 'UTF-8') . '">';
2118 }
2119
2120 foreach ($alternates as $alt) {
2121 if ((string)$alt['lng'] === $defaultLng) {
2122 $lines[] = '<link rel="alternate" hreflang="x-default" href="' . htmlspecialchars((string)$alt['url'], ENT_QUOTES, 'UTF-8') . '">';
2123 break;
2124 }
2125 }
2126
2127 return count($lines) ? "\n " . implode("\n ", $lines) : '';
2128 }
2129
2130 private function seoHreflangPermalink($db, string $lng, string $lngUid, string $escapedUid, array $rec, string $currentLng): string {
2131 if ($lng === $currentLng) {
2132 return trim((string)($rec['permalink'] ?? ''));
2133 }
2134
2135 // lng_uid ist die fachliche Geschwister-ID. Eine direkte DD-Abfrage
2136 // ersetzt das fruehere Durchlaufen des gesamten Permalink-Index samt
2137 // einer Content-Abfrage pro Kandidat.
2138 $sibling = $db->select1(
2139 dbxContentLng::ddContent($lng),
2140 "lng_uid = '" . $escapedUid . "' AND activ = 1",
2141 'permalink',
2142 0
2143 );
2144 if (!is_array($sibling)) {
2145 return '';
2146 }
2147
2148 return trim((string)($sibling['permalink'] ?? ''));
2149 }
2150
2151 private function seoJsonLd(array $rec, string $title, string $description, string $canonical, string $lng): string {
2152 if ($title === '' && $description === '' && $canonical === '') {
2153 return '';
2154 }
2155
2156 $data = array(
2157 '@context' => 'https://schema.org',
2158 '@type' => 'WebPage',
2159 'name' => $title,
2160 'description' => $description,
2161 'url' => $canonical,
2162 'inLanguage' => $lng,
2163 );
2164
2165 $modified = trim((string)($rec['update_date'] ?? ''));
2166 if ($modified !== '') {
2167 $ts = strtotime($modified);
2168 if ($ts !== false) {
2169 $data['dateModified'] = gmdate('c', $ts);
2170 }
2171 }
2172
2173 $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
2174 if (!is_string($json) || $json === '') {
2175 return '';
2176 }
2177
2178 return '<script type="application/ld+json">' . $json . '</script>';
2179 }
2180
2181 private function cleanup_empty_sections($html) {
2182 $html = preg_replace('/<section\b[^>]*class="[^"]*\bcms-header\b[^"]*"[^>]*>\s*<\/section>/i', '', (string)$html);
2183 $html = preg_replace('/<section\b[^>]*class="[^"]*\bgallery\b[^"]*\bno-gallery\b[^"]*"[^>]*>[\s\S]*?<\/section>/i', '', (string)$html);
2184 $html = preg_replace('/<section\b([^>]*class="[^"]*\bdbx-content-(?:gallery-section|teaser-section|header-text)\b[^"]*"[^>]*)>\s*<\/section>/i', '', (string)$html);
2185 $html = preg_replace('/<footer\b([^>]*class="[^"]*\bdbx-content-footer\b[^"]*"[^>]*)>\s*<\/footer>/i', '', $html);
2186 $html = preg_replace('/<div\b([^>]*class="[^"]*\bdbx-content-teaser\b[^"]*"[^>]*)>\s*<\/div>/i', '', $html);
2187 $html = preg_replace('/<section\b([^>]*class="[^"]*\bdbx-content-hero\b[^"]*"[^>]*)>\s*<\/section>/i', '', $html);
2188 $html = preg_replace('/<div\b([^>]*class="[^"]*\bhero-content\b[^"]*"[^>]*)>\s*<\/div>/i', '', $html);
2189 return $html;
2190 }
2191}
2192
2193?>
if(!is_array( $before)||(int)( $before[ 'id'] ?? 0) !==$mediaId) if((string)($before['media_type'] ?? '') !=='video') $updated
static ddFolder(string $lng='')
static ddContent(string $lng='')
renderStatic($cid, array $options=array())
static optimizeContentPageImages(string $html)
Ergaenzt performante Browser-Hinweise in der final gerenderten Content-Seite.
applySeoMeta(int $cid, array $rec=null, array $meta=null)
get_base_dir(int $cutData=0)
Liefert das Basisverzeichnis der Installation.
Definition dbxApi.php:1507
esc($value)
Escaped einen Wert fuer HTML-Text und HTML-Attribute.
Definition dbxApi.php:2310
get_file_dir()
Liefert das files/-Verzeichnis der Installation.
Definition dbxApi.php:1524
next_id(int $add=1)
Erzeugt eine fortlaufende ID innerhalb des DBX-Kontexts.
Definition dbxApi.php:2181
html($html)
Bereitet HTML fuer die Ausgabe auf.
Definition dbxApi.php:2296
dbx_accessible_lngs()
Konfigurierte Sprachen aus dbx-Config.
Definition dbxApi.php:3048
get_base_url()
Liefert die Basis-URL des aktuellen Requests.
Definition dbxApi.php:1488
can($access_groups='', $user_groups='')
Prueft Gruppenrechte gegen den aktuellen oder uebergebenen Benutzer.
Definition dbxApi.php:1381
if($stored !=='files/test/') $resolved
if($web->content_permalink_redirect_target() !=='') $tpl
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
if( $demoId<=0) foreach(array('create_date', 'create_uid', 'update_date', 'update_uid', 'owner',) as $systemField) $items
DBX schema administration.