diff --git a/migrations/pagecheck_setup_20261113.sh b/migrations/pagecheck_setup_20261113.sh
new file mode 100644
index 0000000..318e1ca
--- /dev/null
+++ b/migrations/pagecheck_setup_20261113.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+# vitec_pagecheck (2026-11-13): permissions, syntax check, cache flush, page setup (dry-run unless --apply).
+set -e
+cd /usr/www/users/vitecevo/live
+EXT=packages/vitec
+CFG=$EXT/Configuration
+chmod 644 $EXT/Classes/Command/CreateCheckPagesCommand.php $EXT/Classes/Service/PageCheckService.php $EXT/Classes/UserFunc/PageCheckJsonRenderer.php \
+ $CFG/FlexForms/Pagecheck.xml $CFG/Services.yaml $CFG/Sets/Vitecset/setup.typoscript $CFG/TCA/Overrides/tt_content.php $CFG/page.tsconfig migrations/pagecheck_setup_20261113.sh
+php -l $EXT/Classes/Service/PageCheckService.php
+php -l $EXT/Classes/UserFunc/PageCheckJsonRenderer.php
+php -l $EXT/Classes/Command/CreateCheckPagesCommand.php
+php -l $CFG/TCA/Overrides/tt_content.php
+vendor/bin/typo3 cache:flush
+vendor/bin/typo3 vitec:pagecheck-pages "$@"
diff --git a/packages/vitec/Classes/Command/CreateCheckPagesCommand.php b/packages/vitec/Classes/Command/CreateCheckPagesCommand.php
new file mode 100644
index 0000000..4858255
--- /dev/null
+++ b/packages/vitec/Classes/Command/CreateCheckPagesCommand.php
@@ -0,0 +1,142 @@
+ '/markets', 'title' => 'Market Check', 'slug' => '/markets/market-check', 'header' => 'Market Content Check', 'scope' => 'markets'],
+ ['parentSlug' => '/solutions', 'title' => 'Solution Check', 'slug' => '/solutions/solution-check', 'header' => 'Solution Content Check', 'scope' => 'solutions'],
+ ];
+
+ protected function configure(): void
+ {
+ $this->addOption('apply', null, InputOption::VALUE_NONE, 'Create the pages (default: dry run)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ Bootstrap::initializeBackendAuthentication();
+ $apply = (bool)$input->getOption('apply');
+ $db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
+
+ $template = $db->fetchAssociative(
+ 'SELECT uid, doktype, backend_layout, backend_layout_next_level, no_index, no_follow, nav_hide, no_search, seo_title, description FROM pages WHERE deleted = 0 AND slug = ?',
+ [self::TEMPLATE_SLUG]
+ );
+ if ($template === false) {
+ $output->writeln('Template page ' . self::TEMPLATE_SLUG . ' not found.');
+ return Command::FAILURE;
+ }
+ $output->writeln(sprintf(
+ 'Template %s (uid %d): doktype %d, layout "%s", no_index %d, nav_hide %d, no_search %d',
+ self::TEMPLATE_SLUG, $template['uid'], $template['doktype'], $template['backend_layout'], $template['no_index'], $template['nav_hide'], $template['no_search']
+ ));
+ $output->writeln($apply ? 'APPLY' : 'DRY RUN - nothing is written (--apply to create)');
+
+ $created = 0;
+ foreach (self::PAGES as $def) {
+ $existing = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$def['slug']]);
+ if ($existing !== false) {
+ $output->writeln(sprintf(' %s exists (uid %d) - skipped', $def['slug'], $existing['uid']));
+ continue;
+ }
+ $parent = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$def['parentSlug']]);
+ if ($parent === false) {
+ $output->writeln(sprintf(' parent %s not found - %s skipped', $def['parentSlug'], $def['slug']));
+ continue;
+ }
+ $output->writeln(sprintf(' create %s "%s" under %s (pid %d) + vitec_pagecheck "%s" (scope %s)', $def['slug'], $def['title'], $def['parentSlug'], $parent['uid'], $def['header'], $def['scope']));
+ $created++;
+ if (!$apply) {
+ continue;
+ }
+ $pageId = 'NEWpage' . $def['scope'];
+ $datamap = [
+ 'pages' => [
+ $pageId => [
+ 'pid' => -(int)$this->lastChild((int)$parent['uid'], (int)$parent['uid']),
+ 'title' => $def['title'],
+ 'slug' => $def['slug'],
+ 'doktype' => (int)$template['doktype'],
+ 'backend_layout' => (string)$template['backend_layout'],
+ 'backend_layout_next_level' => (string)$template['backend_layout_next_level'],
+ 'no_index' => (int)$template['no_index'],
+ 'no_follow' => (int)$template['no_follow'],
+ 'nav_hide' => (int)$template['nav_hide'],
+ 'no_search' => (int)$template['no_search'],
+ 'hidden' => 0,
+ ],
+ ],
+ 'tt_content' => [
+ 'NEWce' . $def['scope'] => [
+ 'pid' => $pageId,
+ 'colPos' => 0,
+ 'sys_language_uid' => 0,
+ 'CType' => 'vitec_pagecheck',
+ 'header' => $def['header'],
+ 'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => ['settings.scope' => ['vDEF' => $def['scope']]]]]],
+ ],
+ ],
+ ];
+ $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
+ $dataHandler->start($datamap, []);
+ $dataHandler->process_datamap();
+ if ($dataHandler->errorLog !== []) {
+ $output->writeln(' DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '');
+ return Command::FAILURE;
+ }
+ foreach ($dataHandler->substNEWwithIDs as $new => $uid) {
+ $output->writeln(sprintf(' written: %s -> #%d', $new, $uid));
+ }
+ }
+ $output->writeln(sprintf('%s: %d page(s).', $apply ? 'DONE' : 'DRY RUN - would create', $created));
+ if ($apply && $created > 0) {
+ $output->writeln('Afterwards: vendor/bin/typo3 cache:flush');
+ }
+ return Command::SUCCESS;
+ }
+
+ /** uid of the last live child page (to append after it), or the parent itself when there is none */
+ private function lastChild(int $parentUid, int $fallback): int
+ {
+ $uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
+ 'SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND pid = ? ORDER BY sorting DESC LIMIT 1',
+ [$parentUid]
+ );
+ return $uid === false ? $fallback : (int)$uid;
+ }
+}
diff --git a/packages/vitec/Classes/Service/PageCheckService.php b/packages/vitec/Classes/Service/PageCheckService.php
new file mode 100644
index 0000000..3445c37
--- /dev/null
+++ b/packages/vitec/Classes/Service/PageCheckService.php
@@ -0,0 +1,624 @@
+ [
+ 'label' => 'Markets',
+ 'rootSlug' => '/markets',
+ 'types' => ['market', 'submarket'],
+ 'recordTable' => 'tx_vitec_domain_model_market',
+ ],
+ 'solutions' => [
+ 'label' => 'Solutions',
+ 'rootSlug' => '/solutions',
+ 'types' => ['solution', 'subsolution'],
+ 'recordTable' => 'tx_vitec_domain_model_solution',
+ ],
+ ];
+
+ private const ALL = ['market', 'submarket', 'solution', 'subsolution'];
+
+ /**
+ * Columns in display order. kind as in the product check (images / video /
+ * text / features / list), applies = page types that have the column.
+ */
+ private const FIELDS = [
+ ['key' => 'hero', 'label' => 'Hero image', 'kind' => 'images', 'applies' => self::ALL],
+ ['key' => 'subline', 'label' => 'Hero subline', 'kind' => 'text', 'applies' => self::ALL],
+ ['key' => 'lead', 'label' => 'Lead', 'kind' => 'text', 'applies' => ['solution', 'subsolution']],
+ ['key' => 'intro', 'label' => 'Intro text', 'kind' => 'text', 'applies' => self::ALL],
+ ['key' => 'introImage', 'label' => 'Intro image', 'kind' => 'images', 'applies' => self::ALL],
+ ['key' => 'introCta', 'label' => 'Intro CTA', 'kind' => 'text', 'applies' => self::ALL],
+ ['key' => 'cards', 'label' => 'Cards', 'kind' => 'features', 'applies' => self::ALL],
+ ['key' => 'stories', 'label' => 'Testimonials', 'kind' => 'list', 'applies' => self::ALL],
+ ['key' => 'products', 'label' => 'Products', 'kind' => 'list', 'applies' => ['submarket', 'subsolution']],
+ ['key' => 'insights', 'label' => 'Insights text', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
+ ['key' => 'insightsCover', 'label' => 'Insights cover', 'kind' => 'images', 'applies' => ['submarket', 'solution', 'subsolution']],
+ ['key' => 'insightsCta', 'label' => 'Insights CTA', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
+ ['key' => 'logos', 'label' => 'Logos', 'kind' => 'list', 'applies' => ['market']],
+ ['key' => 'cta', 'label' => 'CTA banner', 'kind' => 'text', 'applies' => ['market', 'solution']],
+ ['key' => 'seo', 'label' => 'Meta description', 'kind' => 'text', 'applies' => self::ALL],
+ ['key' => 'og', 'label' => 'OG image', 'kind' => 'images', 'applies' => self::ALL],
+ ['key' => 'media', 'label' => 'Page media', 'kind' => 'images', 'applies' => self::ALL],
+ ['key' => 'record', 'label' => 'Record', 'kind' => 'list', 'applies' => self::ALL],
+ ];
+
+ private const HERO_COLPOS = [1, 110];
+ private const CONTAINER_PREFIXES = ['vitec_cols_', 'vitec_container', 'vitec_cards_carousel'];
+
+ /** Text fields per element type for the detail pane: column => label. */
+ private const ELEMENT_TEXTS = [
+ 'vitec_markethero' => ['vitec_subline' => 'Subline', 'vitec_cta_label' => 'CTA label', 'vitec_video_title' => 'Video title'],
+ 'vitec_herosection' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_cta_label' => 'CTA label'],
+ 'vitec_introparagraph' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_button_text' => 'Button'],
+ 'vitec_marketintro' => ['vitec_intro_headline' => 'Headline', 'vitec_intro_text' => 'Text', 'vitec_learnmore_label' => 'Button'],
+ 'vitec_card' => ['vitec_eyebrow' => 'Eyebrow', 'header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_back_headline' => 'Back headline', 'vitec_back_text' => 'Back text', 'vitec_primary_cta_label' => 'Button'],
+ 'vitec_usecaselist' => ['header' => 'Header'],
+ 'vitec_customerlogos' => ['header' => 'Header'],
+ 'vitec_marketinsights' => ['vitec_text1' => 'Text 1', 'vitec_text2' => 'Text 2', 'vitec_cta_label' => 'CTA label', 'vitec_video_person' => 'Video person', 'vitec_video_role' => 'Video role', 'vitec_video_title' => 'Video title'],
+ 'vitec_ctabanner' => ['header' => 'Header', 'bodytext' => 'Text', 'vitec_primary_cta_label' => 'Primary button', 'vitec_secondary_cta_label' => 'Secondary button'],
+ 'vitec_productfeature' => ['header' => 'Header'],
+ 'vitec_productlist' => ['header' => 'Header'],
+ ];
+
+ /** Link fields per element type: column => label. */
+ private const ELEMENT_LINKS = [
+ 'vitec_markethero' => ['vitec_cta_link' => 'CTA link', 'vitec_video_url' => 'Video URL'],
+ 'vitec_herosection' => ['vitec_cta' => 'CTA link'],
+ 'vitec_introparagraph' => ['vitec_button_link' => 'Button link'],
+ 'vitec_marketintro' => ['vitec_learnmore_target' => 'Button target'],
+ 'vitec_card' => ['vitec_primary_cta' => 'Button link', 'vitec_card_link' => 'Card link'],
+ 'vitec_marketinsights' => ['vitec_cta_target' => 'CTA target', 'vitec_video_url' => 'Video URL'],
+ 'vitec_ctabanner' => ['vitec_primary_cta' => 'Primary link', 'vitec_secondary_cta' => 'Secondary link'],
+ ];
+
+ private const ELEMENT_LABELS = [
+ 'vitec_markethero' => 'Hero',
+ 'vitec_herosection' => 'Hero section',
+ 'vitec_introparagraph' => 'Intro paragraph',
+ 'vitec_marketintro' => 'Intro (text | image)',
+ 'vitec_card' => 'Card',
+ 'vitec_usecaselist' => 'Testimonials',
+ 'vitec_customerlogos' => 'Customer logos',
+ 'vitec_marketinsights' => 'Insights',
+ 'vitec_ctabanner' => 'CTA banner',
+ 'vitec_productfeature' => 'Featured product',
+ 'vitec_productlist' => 'Product list',
+ ];
+
+ public function __construct(
+ private readonly ConnectionPool $connectionPool,
+ ) {}
+
+ /** @return array */
+ public function overview(string $scope): array
+ {
+ $cfg = self::SCOPES[$scope] ?? throw new \InvalidArgumentException('Unknown scope ' . $scope);
+ $pages = $this->pages($cfg);
+ $pageUids = array_map(static fn(array $p): int => (int)$p['uid'], $pages);
+ $elementsByPage = $this->elementsByPage($pageUids);
+ $allElementUids = [];
+ foreach ($elementsByPage as $els) {
+ foreach ($els as $el) {
+ $allElementUids[] = (int)$el['uid'];
+ }
+ }
+ $elementFiles = $this->referenceCounts('tt_content', $allElementUids);
+ $pageFiles = $this->referenceCounts('pages', $pageUids);
+ $records = $this->records($cfg['recordTable'], $pageUids);
+ $recordFiles = $this->referenceCounts($cfg['recordTable'], array_map(static fn(array $r): int => (int)$r['uid'], $records));
+ // Empty selection = what the testimonial band shows: quoted stories,
+ // at most twelve (UsecaseListJsonRenderer::testimonialRows).
+ $allStories = min(12, $this->countAll('tx_vitec_domain_model_usecase', "deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND TRIM(COALESCE(quote, '')) <> ''"));
+ $allCustomers = $this->countAll('tx_vitec_domain_model_customer', 'deleted = 0 AND hidden = 0');
+
+ $keys = array_column(self::FIELDS, 'key');
+ $filled = array_fill_keys($keys, 0);
+ $applicable = array_fill_keys($keys, 0);
+ $byType = [];
+ $rows = [];
+ foreach ($pages as $p) {
+ $uid = (int)$p['uid'];
+ $type = (string)$p['_type'];
+ $byType[$type] = ($byType[$type] ?? 0) + 1;
+ $els = $elementsByPage[$uid] ?? [];
+ $visible = array_values(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 0));
+ $hiddenCount = count($els) - count($visible);
+ $record = $records[$uid] ?? null;
+ $raw = $this->pageChecks($type, $visible, $elementFiles, $pageFiles[$uid] ?? [], $p, $record, $record ? ($recordFiles[(int)$record['uid']] ?? []) : [], $allStories, $allCustomers);
+
+ $checks = [];
+ foreach (self::FIELDS as $field) {
+ $key = $field['key'];
+ if (!in_array($type, $field['applies'], true)) {
+ $checks[$key] = ['ok' => null, 'count' => 0];
+ continue;
+ }
+ $checks[$key] = $raw[$key];
+ $applicable[$key]++;
+ if ($raw[$key]['ok'] === true) {
+ $filled[$key]++;
+ }
+ }
+ $rows[] = $this->base($p, $type, $record, $hiddenCount) + ['checks' => $checks];
+ }
+
+ return [
+ 'scope' => $scope,
+ 'scopeLabel' => $cfg['label'],
+ 'generatedAt' => date(\DATE_ATOM),
+ 'fields' => self::FIELDS,
+ 'summary' => ['pages' => count($rows), 'byType' => $byType, 'filled' => $filled, 'applicable' => $applicable],
+ 'pages' => $rows,
+ ];
+ }
+
+ /** @return array|null null when the page is not part of any scope */
+ public function detail(int $uid): ?array
+ {
+ foreach (self::SCOPES as $scope => $cfg) {
+ foreach ($this->pages($cfg) as $p) {
+ if ((int)$p['uid'] !== $uid) {
+ continue;
+ }
+ $type = (string)$p['_type'];
+ $records = $this->records($cfg['recordTable'], [$uid]);
+ $record = $records[$uid] ?? null;
+ $els = $this->elementsByPage([$uid])[$uid] ?? [];
+ $hiddenCount = count(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 1));
+ $flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
+
+ $elements = [];
+ foreach ($els as $el) {
+ $cType = (string)$el['CType'];
+ $texts = [];
+ foreach (self::ELEMENT_TEXTS[$cType] ?? ['header' => 'Header', 'bodytext' => 'Text'] as $column => $label) {
+ $value = (string)($el[$column] ?? '');
+ $texts[] = ['key' => $column, 'label' => $label, 'ok' => $this->filled($value), 'html' => $value];
+ }
+ $links = [];
+ foreach (self::ELEMENT_LINKS[$cType] ?? [] as $column => $label) {
+ $value = trim((string)($el[$column] ?? ''));
+ if ($value !== '') {
+ $links[] = ['key' => $column, 'label' => $label, 'target' => $value];
+ }
+ }
+ $settings = [];
+ if (trim((string)($el['pi_flexform'] ?? '')) !== '') {
+ $settings = $flexFormService->convertFlexFormContentToArray((string)$el['pi_flexform'])['settings'] ?? [];
+ $settings = array_filter($settings, static fn($v): bool => is_scalar($v) && (string)$v !== '' && (string)$v !== '0');
+ $settings = array_map(static fn($v): string => (string)$v, $settings);
+ }
+ $elements[] = [
+ 'uid' => (int)$el['uid'],
+ 'type' => $cType,
+ 'label' => self::ELEMENT_LABELS[$cType] ?? $cType,
+ 'colPos' => (int)$el['colPos'],
+ 'slot' => in_array((int)$el['colPos'], self::HERO_COLPOS, true) ? 'hero' : 'main',
+ 'hidden' => (int)$el['hidden'] === 1,
+ 'header' => (string)($el['header'] ?? ''),
+ 'layoutVariant' => (string)($el['vitec_layout_variant'] ?? ''),
+ 'texts' => $texts,
+ 'links' => $links,
+ 'images' => $this->files('tt_content', (int)$el['uid']),
+ 'settings' => $settings,
+ ];
+ }
+
+ $children = [];
+ if ((int)$p['_level'] === 1) {
+ foreach ($this->pages($cfg) as $c) {
+ if ((int)$c['pid'] === $uid) {
+ $children[] = ['uid' => (int)$c['uid'], 'title' => (string)$c['title'], 'url' => (string)$c['slug'], 'hidden' => (int)$c['hidden'] === 1];
+ }
+ }
+ }
+
+ return $this->base($p, $type, $record, $hiddenCount) + [
+ 'seo' => [
+ 'title' => (string)$p['title'],
+ 'seoTitle' => (string)($p['seo_title'] ?? ''),
+ 'description' => (string)($p['description'] ?? ''),
+ 'og' => $this->files('pages', $uid, ['og_image']),
+ ],
+ 'media' => $this->files('pages', $uid, ['media']),
+ 'record' => $record === null ? null : [
+ 'uid' => (int)$record['uid'],
+ 'title' => (string)$record['title'],
+ 'subtitle' => (string)($record['subtitle'] ?? ''),
+ 'teaser' => (string)($record['teaser'] ?? ''),
+ 'description' => (string)($record['description'] ?? ''),
+ 'image' => $this->files($cfg['recordTable'], (int)$record['uid'], ['image']),
+ ],
+ 'elements' => $elements,
+ 'children' => $children,
+ ];
+ }
+ }
+ return null;
+ }
+
+ // ---- checks -------------------------------------------------------------
+
+ /**
+ * @param array> $visible visible elements in page order
+ * @param array> $elementFiles [elementUid][fieldname] = count
+ * @param array $pageFiles [fieldname] = count
+ * @param array $page
+ * @param array|null $record
+ * @param array $recordFiles
+ * @return array
+ */
+ private function pageChecks(string $type, array $visible, array $elementFiles, array $pageFiles, array $page, ?array $record, array $recordFiles, int $allStories, int $allCustomers): array
+ {
+ $hero = $this->firstOf($visible, 'vitec_markethero') ?? $this->firstOf($visible, 'vitec_herosection');
+ $heroFiles = $hero ? ($elementFiles[(int)$hero['uid']] ?? []) : [];
+ $lead = null;
+ foreach ($visible as $el) {
+ if ($el['CType'] === 'vitec_introparagraph' && in_array((int)$el['colPos'], self::HERO_COLPOS, true)) {
+ $lead = $el;
+ break;
+ }
+ }
+ $intro = $this->firstOf($visible, 'vitec_marketintro');
+ $introFiles = $intro ? ($elementFiles[(int)$intro['uid']] ?? []) : [];
+
+ $cards = array_values(array_filter($visible, static fn(array $e): bool => $e['CType'] === 'vitec_card'));
+ $cardVisuals = 0;
+ foreach ($cards as $c) {
+ $f = $elementFiles[(int)$c['uid']] ?? [];
+ if (($f['vitec_icon'] ?? 0) > 0 || ($f['vitec_image'] ?? 0) > 0) {
+ $cardVisuals++;
+ }
+ }
+
+ $stories = $this->firstOf($visible, 'vitec_usecaselist');
+ $storyCount = 0;
+ if ($stories) {
+ $sel = $this->flexSettings((string)$stories['pi_flexform']);
+ $selected = GeneralUtility::intExplode(',', (string)($sel['usecases'] ?? ''), true);
+ $storyCount = $selected === [] ? $allStories : count($selected);
+ }
+
+ $productCount = 0;
+ $hasProducts = false;
+ foreach ($visible as $el) {
+ if ($el['CType'] === 'vitec_productfeature') {
+ $hasProducts = true;
+ $productCount += (int)(($this->flexSettings((string)$el['pi_flexform'])['product'] ?? 0) > 0);
+ } elseif ($el['CType'] === 'vitec_productlist') {
+ $hasProducts = true;
+ $sel = $this->flexSettings((string)$el['pi_flexform']);
+ $productCount += count(GeneralUtility::intExplode(',', (string)($sel['selectedproducts'] ?? ''), true));
+ }
+ }
+
+ $insights = $this->firstOf($visible, 'vitec_marketinsights');
+ $insightsFiles = $insights ? ($elementFiles[(int)$insights['uid']] ?? []) : [];
+
+ $logos = $this->firstOf($visible, 'vitec_customerlogos');
+ $logoCount = 0;
+ if ($logos) {
+ $sel = $this->flexSettings((string)$logos['pi_flexform']);
+ $selected = GeneralUtility::intExplode(',', (string)($sel['customers'] ?? ''), true);
+ $logoCount = $selected === [] || ($sel['onlySelected'] ?? '0') !== '1' ? $allCustomers : count($selected);
+ }
+
+ $cta = $this->firstOf($visible, 'vitec_ctabanner');
+
+ $recordFilled = 0;
+ if ($record !== null) {
+ $recordFilled += (($recordFiles['image'] ?? 0) > 0) ? 1 : 0;
+ $recordFilled += $this->filled($record['teaser'] ?? '') ? 1 : 0;
+ $recordFilled += $this->filled($record['description'] ?? '') ? 1 : 0;
+ }
+
+ return [
+ 'hero' => $this->check(($heroFiles['vitec_bgimage'] ?? 0) + ($heroFiles['vitec_hero_bgimage'] ?? 0) + ($heroFiles['vitec_hero_image'] ?? 0)),
+ 'subline' => $this->check($hero && ($this->filled($hero['vitec_subline'] ?? '') || $this->filled($hero['subheader'] ?? '')) ? 1 : 0),
+ 'lead' => $this->check($lead && $this->filled($lead['bodytext'] ?? '') ? 1 : 0),
+ 'intro' => $this->check($intro && $this->filled($intro['vitec_intro_text'] ?? '') ? 1 : 0),
+ 'introImage' => $this->check($introFiles['vitec_image'] ?? 0),
+ 'introCta' => $this->check($intro && $this->filled($intro['vitec_learnmore_label'] ?? '') && $this->filled($intro['vitec_learnmore_target'] ?? '') ? 1 : 0),
+ 'cards' => $this->check(count($cards)) + ['icons' => $cardVisuals],
+ 'stories' => ['ok' => $stories !== null, 'count' => $storyCount],
+ 'products' => ['ok' => $hasProducts, 'count' => $productCount],
+ 'insights' => $this->check($insights && $this->filled($insights['vitec_text1'] ?? '') ? 1 : 0),
+ 'insightsCover' => $this->check($insightsFiles['vitec_video_cover'] ?? 0),
+ 'insightsCta' => $this->check($insights && $this->filled($insights['vitec_cta_label'] ?? '') && $this->filled($insights['vitec_cta_target'] ?? '') ? 1 : 0),
+ 'logos' => ['ok' => $logos !== null, 'count' => $logoCount],
+ 'cta' => $this->check($cta && $this->filled($cta['vitec_primary_cta_label'] ?? '') ? 1 : 0),
+ 'seo' => $this->check($this->filled($page['description'] ?? '') ? 1 : 0),
+ 'og' => $this->check($pageFiles['og_image'] ?? 0),
+ 'media' => $this->check($pageFiles['media'] ?? 0),
+ 'record' => ['ok' => $record !== null && ($recordFiles['image'] ?? 0) > 0 && $this->filled($record['teaser'] ?? ''), 'count' => $recordFilled],
+ ];
+ }
+
+ // ---- building blocks ----------------------------------------------------
+
+ /**
+ * @param array $p
+ * @param array|null $record
+ */
+ private function base(array $p, string $type, ?array $record, int $hiddenElements): array
+ {
+ $flags = [];
+ if ((int)$p['hidden'] === 1) {
+ $flags[] = 'hidden';
+ }
+ if ((int)$p['nav_hide'] === 1) {
+ $flags[] = 'navHide';
+ }
+ if ((int)$p['no_index'] === 1) {
+ $flags[] = 'noindex';
+ }
+ if ($record === null) {
+ $flags[] = 'noRecord';
+ }
+ if ($hiddenElements > 0) {
+ $flags[] = 'hiddenElements:' . $hiddenElements;
+ }
+ return [
+ 'uid' => (int)$p['uid'],
+ 'title' => (string)$p['title'],
+ 'slug' => trim((string)$p['slug'], '/'),
+ 'url' => (string)$p['slug'],
+ 'type' => $type,
+ 'level' => (int)$p['_level'],
+ 'parent' => (int)$p['pid'],
+ 'flags' => $flags,
+ ];
+ }
+
+ /** @return array{ok: bool, count: int} */
+ private function check(int $count): array
+ {
+ return ['ok' => $count > 0, 'count' => $count];
+ }
+
+ private function filled(mixed $value): bool
+ {
+ return trim(strip_tags((string)$value)) !== '';
+ }
+
+ /** @param array> $elements */
+ private function firstOf(array $elements, string $cType): ?array
+ {
+ foreach ($elements as $el) {
+ if ($el['CType'] === $cType) {
+ return $el;
+ }
+ }
+ return null;
+ }
+
+ /** @return array */
+ private function flexSettings(string $xml): array
+ {
+ if (trim($xml) === '') {
+ return [];
+ }
+ $settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? [];
+ return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : [];
+ }
+
+ /**
+ * The scope's pages: level 1 = children of the root page, level 2 = their
+ * children. Standard pages only, in tree order; a page carrying the check
+ * element itself is left out.
+ *
+ * @param array{rootSlug: string, types: array} $cfg
+ * @return array> rows with _level and _type
+ */
+ private function pages(array $cfg): array
+ {
+ $conn = $this->connection('pages');
+ $root = $conn->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$cfg['rootSlug']]);
+ if ($root === false) {
+ return [];
+ }
+ $select = 'SELECT uid, pid, title, slug, hidden, nav_hide, no_index, description, seo_title, sorting FROM pages
+ WHERE deleted = 0 AND sys_language_uid = 0 AND doktype = 1 AND pid IN (%s) ORDER BY pid, sorting';
+ $level1 = $conn->fetchAllAssociative(sprintf($select, (int)$root['uid']));
+ $checkPages = $this->pagesWithCheckElement();
+ $out = [];
+ foreach ($level1 as $p1) {
+ if (isset($checkPages[(int)$p1['uid']])) {
+ continue;
+ }
+ $out[] = $p1 + ['_level' => 1, '_type' => $cfg['types'][0]];
+ $level2 = $conn->fetchAllAssociative(sprintf($select, (int)$p1['uid']));
+ foreach ($level2 as $p2) {
+ if (isset($checkPages[(int)$p2['uid']])) {
+ continue;
+ }
+ $out[] = $p2 + ['_level' => 2, '_type' => $cfg['types'][1]];
+ }
+ }
+ return $out;
+ }
+
+ /** @return array page uid => true for pages that hold a check element */
+ private function pagesWithCheckElement(): array
+ {
+ $out = [];
+ foreach ($this->connection('tt_content')->fetchFirstColumn(
+ "SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN ('vitec_pagecheck', 'vitec_productcheck')"
+ ) as $pid) {
+ $out[(int)$pid] = true;
+ }
+ return $out;
+ }
+
+ /**
+ * All non-deleted default-language elements of the pages, page order,
+ * containers themselves left out (their children count as page content).
+ *
+ * @param int[] $pageUids
+ * @return array>> [pageUid] = rows
+ */
+ private function elementsByPage(array $pageUids): array
+ {
+ if ($pageUids === []) {
+ return [];
+ }
+ $rows = $this->connection('tt_content')->fetchAllAssociative(
+ 'SELECT * FROM tt_content WHERE deleted = 0 AND sys_language_uid = 0 AND pid IN ('
+ . implode(',', array_map('intval', $pageUids)) . ') ORDER BY pid, colPos, sorting, uid'
+ );
+ // page order: hero slot first, then the main column(s) by colPos and sorting
+ usort($rows, static function (array $a, array $b): int {
+ $heroA = in_array((int)$a['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
+ $heroB = in_array((int)$b['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
+ return [(int)$a['pid'], $heroA, (int)$a['colPos'], (int)$a['sorting'], (int)$a['uid']]
+ <=> [(int)$b['pid'], $heroB, (int)$b['colPos'], (int)$b['sorting'], (int)$b['uid']];
+ });
+ $out = [];
+ foreach ($rows as $row) {
+ $cType = (string)$row['CType'];
+ foreach (self::CONTAINER_PREFIXES as $prefix) {
+ if (str_starts_with($cType, $prefix)) {
+ continue 2;
+ }
+ }
+ $out[(int)$row['pid']][] = $row;
+ }
+ return $out;
+ }
+
+ /**
+ * Visible file references per record and field.
+ *
+ * @param int[] $uids
+ * @return array> [uid][fieldname] = count
+ */
+ private function referenceCounts(string $table, array $uids): array
+ {
+ if ($uids === []) {
+ return [];
+ }
+ $rows = $this->connection('sys_file_reference')->fetchAllAssociative(
+ 'SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference
+ WHERE deleted = 0 AND hidden = 0 AND tablenames = ? AND uid_foreign IN (' . implode(',', array_map('intval', $uids)) . ')
+ GROUP BY uid_foreign, fieldname',
+ [$table]
+ );
+ $out = [];
+ foreach ($rows as $r) {
+ $out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n'];
+ }
+ return $out;
+ }
+
+ /**
+ * Files of one record with original URL and a ~480 px thumbnail.
+ *
+ * @param string[]|null $fieldnames null = all fields
+ * @return array
+ */
+ private function files(string $table, int $uid, ?array $fieldnames = null): array
+ {
+ $sql = "SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type
+ FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
+ WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = ? AND r.uid_foreign = ?";
+ $params = [$table, $uid];
+ if ($fieldnames !== null) {
+ $sql .= ' AND r.fieldname IN (' . implode(',', array_fill(0, count($fieldnames), '?')) . ')';
+ $params = array_merge($params, $fieldnames);
+ }
+ $sql .= ' ORDER BY r.fieldname, r.sorting_foreign';
+ $out = [];
+ foreach ($this->connection('sys_file_reference')->fetchAllAssociative($sql, $params) as $r) {
+ $url = '/fileadmin' . $r['identifier'];
+ $out[] = [
+ 'field' => (string)preg_replace('/^vitec_/', '', (string)$r['fieldname']),
+ 'url' => $url,
+ 'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url),
+ 'name' => (string)$r['name'],
+ 'mime' => (string)$r['mime_type'],
+ ];
+ }
+ return $out;
+ }
+
+ private function thumbnail(int $fileUid, string $mime, string $fallback): string
+ {
+ if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
+ return $fallback;
+ }
+ try {
+ $file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
+ $imageService = GeneralUtility::makeInstance(ImageService::class);
+ $processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]);
+ return $imageService->getImageUri($processed);
+ } catch (\Throwable) {
+ return $fallback;
+ }
+ }
+
+ /**
+ * Records pointing to the pages via detail_page (first live record per page).
+ *
+ * @param int[] $pageUids
+ * @return array> [pageUid] = record row
+ */
+ private function records(string $table, array $pageUids): array
+ {
+ if ($pageUids === []) {
+ return [];
+ }
+ $rows = $this->connection($table)->fetchAllAssociative(
+ 'SELECT uid, title, subtitle, teaser, description, detail_page FROM ' . $table . '
+ WHERE deleted = 0 AND detail_page IN (' . implode(',', array_map('intval', $pageUids)) . ') ORDER BY hidden, uid'
+ );
+ $out = [];
+ foreach ($rows as $r) {
+ $out[(int)$r['detail_page']] ??= $r;
+ }
+ return $out;
+ }
+
+ private function countAll(string $table, string $where): int
+ {
+ return (int)$this->connection($table)->fetchOne("SELECT COUNT(*) FROM $table WHERE $where");
+ }
+
+ private function connection(string $table): Connection
+ {
+ return $this->connectionPool->getConnectionForTable($table);
+ }
+}
diff --git a/packages/vitec/Classes/UserFunc/PageCheckJsonRenderer.php b/packages/vitec/Classes/UserFunc/PageCheckJsonRenderer.php
new file mode 100644
index 0000000..264f090
--- /dev/null
+++ b/packages/vitec/Classes/UserFunc/PageCheckJsonRenderer.php
@@ -0,0 +1,68 @@
+ {mode: "detail", page: {…} | null}
+ * on failure {mode: "error", error}
+ *
+ * The scope (markets | solutions) comes from the element's FlexForm
+ * (settings.scope). Always USER_INT - the audit shows the database as it is
+ * right now; `check` is excluded from the cHash (ext_localconf.php, shared
+ * with the product check). The front end loads the detail variant
+ * client-side from /_json, one request per opened row.
+ */
+final class PageCheckJsonRenderer
+{
+ protected ?ContentObjectRenderer $cObj = null;
+
+ public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
+ {
+ $this->cObj = $cObj;
+ }
+
+ #[AsAllowedCallable]
+ public function render(string $content, array $conf): string
+ {
+ if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) {
+ $this->cObj->convertToUserIntObject();
+ return '';
+ }
+ try {
+ $scope = 'markets';
+ $flex = (string)($this->cObj?->data['pi_flexform'] ?? '');
+ if (trim($flex) !== '') {
+ $settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($flex)['settings'] ?? [];
+ if (isset(PageCheckService::SCOPES[(string)($settings['scope'] ?? '')])) {
+ $scope = (string)$settings['scope'];
+ }
+ }
+ $service = GeneralUtility::makeInstance(PageCheckService::class);
+ $queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
+ $check = (int)($queryParams['check'] ?? 0);
+ if ($check > 0) {
+ $payload = ['mode' => 'detail', 'scope' => $scope, 'page' => $service->detail($check)];
+ } else {
+ $payload = ['mode' => 'overview'] + $service->overview($scope);
+ }
+ } catch (\Throwable $e) {
+ $payload = ['mode' => 'error', 'error' => $e->getMessage()];
+ }
+ return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+ }
+}
diff --git a/packages/vitec/Configuration/FlexForms/Pagecheck.xml b/packages/vitec/Configuration/FlexForms/Pagecheck.xml
new file mode 100644
index 0000000..c7d98f4
--- /dev/null
+++ b/packages/vitec/Configuration/FlexForms/Pagecheck.xml
@@ -0,0 +1,35 @@
+
+
+
+ 1
+
+
+
+
+ Page Content Check
+ array
+
+
+
+ Markets = the 11 markets and their sub-markets below /markets. Solutions = the 8 solution groups and their sub-solutions below /solutions.
+
+ select
+ selectSingle
+
+
+
+ markets
+
+
+
+ solutions
+
+
+ markets
+
+
+
+
+
+
+
diff --git a/packages/vitec/Configuration/Services.yaml b/packages/vitec/Configuration/Services.yaml
index b031bae..b2a2e66 100644
--- a/packages/vitec/Configuration/Services.yaml
+++ b/packages/vitec/Configuration/Services.yaml
@@ -32,6 +32,8 @@ services:
# ProductCheckJsonRenderer (TypoScript userFunc, no DI there).
Evomedien\Vitec\Service\ProductCheckService:
public: true
+ Evomedien\Vitec\Service\PageCheckService:
+ public: true
# SSR dashboard tiles (the WidgetConfigurationInterface argument is
# injected by the dashboard compiler pass via the tag).
diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
index 18b8607..0c0bf95 100755
--- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
+++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript
@@ -114,6 +114,21 @@ tt_content {
}
}
+ # Page Content Check - the market / solution page audit as data (pages
+ # /markets/market-check, /solutions/solution-check). Always USER_INT, see PageCheckJsonRenderer.
+ vitec_pagecheck < lib.contentElementWithHeader
+ vitec_pagecheck {
+ fields {
+ content {
+ fields {
+ pagecheck = USER
+ pagecheck.userFunc = Evomedien\Vitec\UserFunc\PageCheckJsonRenderer->render
+ }
+ }
+ }
+ }
+
+
# EXT:solr search results as JSON - the search endpoint for the React frontend
solr_pi_results < lib.contentElementWithHeader
solr_pi_results {
diff --git a/packages/vitec/Configuration/TCA/Overrides/tt_content.php b/packages/vitec/Configuration/TCA/Overrides/tt_content.php
index f224fd9..131bd08 100644
--- a/packages/vitec/Configuration/TCA/Overrides/tt_content.php
+++ b/packages/vitec/Configuration/TCA/Overrides/tt_content.php
@@ -37,6 +37,10 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
'plugins',
'Live audit of every product record: one row per product, one check per content field, details per row. Place once on /products/product-check.'
);
+ // Page content audit for the market and solution trees (2026-11-13):
+ // scope (markets | solutions) via FlexForm, data from PageCheckService.
+ $registerPluginWithFlexForm('Pagecheck', 'Page Content Check (markets / solutions audit)', 'FILE:EXT:vitec/Configuration/FlexForms/Pagecheck.xml', 'vitec-plugin-productlist');
+
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
diff --git a/packages/vitec/Configuration/page.tsconfig b/packages/vitec/Configuration/page.tsconfig
index aef489e..30320e0 100755
--- a/packages/vitec/Configuration/page.tsconfig
+++ b/packages/vitec/Configuration/page.tsconfig
@@ -40,6 +40,15 @@ mod {
CType = vitec_productcheck
}
}
+ pagecheck {
+ iconIdentifier = vitec-plugin-productlist
+ title = Page Content Check (markets / solutions audit)
+ description = Live audit of the market or solution page tree - one row per page, one check per content slot. Internal pages /markets/market-check and /solutions/solution-check.
+ tt_content_defValues {
+ CType = vitec_pagecheck
+ }
+ }
+
productshow {
iconIdentifier = vitec-plugin-productshow
title = Show single VITEC Product