From 47c8e2902f031fafc43745fac541bd71024f3500 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Thu, 12 Nov 2026 19:14:05 +0100 Subject: [PATCH] Market pages: elements for the Figma structure (0124-FE) New command vitec:markets-figma-elements (dry-run by default, --apply writes) for the 11 main market pages: numbered placeholder image placeholders/pages/-02.png and learnmore_target /contact on vitec_marketintro, the hidden Trusted-by and Case-studies shells of the 2026-10-23 page import unhidden (logos with the home page settings, case studies as testimonials with the empty selection = all stories, like the sub-market pages), the lead paragraph in the hero slot hidden (decision aehm 12.11., not deleted). Sub-market pages: vitec_marketinsights CTA /success-stories -> /insights/news with the label Explore our insights. Applied on DEV 2026-11-12: 11 images, 11 targets, 11 logo bands, 11 sliders, 11 hidden leads, 24 CTAs, 0 warnings; protocol in migrations/markets_figma_apply_20261112.txt. Co-Authored-By: Claude Fable 5.1 --- .../Command/MarketsFigmaElementsCommand.php | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 packages/vitec/Classes/Command/MarketsFigmaElementsCommand.php diff --git a/packages/vitec/Classes/Command/MarketsFigmaElementsCommand.php b/packages/vitec/Classes/Command/MarketsFigmaElementsCommand.php new file mode 100644 index 0000000..68ffe1f --- /dev/null +++ b/packages/vitec/Classes/Command/MarketsFigmaElementsCommand.php @@ -0,0 +1,510 @@ +, 11 pages): + * + * B1 vitec_marketintro: numbered placeholder image placeholders/pages/-02.png + * when the element has no image yet; learnmore_target "/contact" when empty. + * B2 vitec_customerlogos ("Trusted by"): the 2026-10-23 page import left a + * HIDDEN shell after the sub-market cards - it is unhidden and gets the + * settings of the home page element (#758: layout, customer selection). + * A page without a shell gets a copy of #758 instead. + * B3 vitec_usecaselist ("Case studies" shell, hidden as well): unhidden as + * layout testimonials. Stories = what an editor already picked on the + * shell, else the union of the market's sub-market testimonial lists, + * else the most common sub-market list. No shell: a new element after + * the logos, i.e. before the CTA banner. + * B5 the lead paragraph in the hero slot (vitec_introparagraph, colPos 1) + * is HIDDEN, not deleted (decision aehm 12.11.: copy raw material for Joe). + * + * Sub-market pages (children of the 11): + * B4 vitec_marketinsights: cta_target /success-stories -> /insights/news, + * label "Explore success stories" -> "Explore our insights" (placeholder). + * + * Idempotent: elements that already exist are left alone, filled fields are + * never overwritten. Everything goes through DataHandler (history, refindex, + * SSR auto-purge), still run cache:flush + vitec:ssr-purge/-warm afterwards - + * the command prints the exact lines. + * + * vendor/bin/typo3 vitec:markets-figma-elements # dry run (default) + * vendor/bin/typo3 vitec:markets-figma-elements --apply # writes + */ +#[AsCommand( + name: 'vitec:markets-figma-elements', + description: 'Create the Figma structure elements on the 11 main market pages (0124-FE, dry-run by default)' +)] +final class MarketsFigmaElementsCommand extends Command +{ + private const MARKET_SLUGS = [ + '/markets/broadcast-and-telecoms', + '/markets/corporation', + '/markets/critical-national-infrastructure', + '/markets/defense-and-security', + '/markets/education', + '/markets/government', + '/markets/hospitality', + '/markets/industrial', + '/markets/public-safety', + '/markets/sports-and-entertainment', + '/markets/transport', + ]; + private const LOGO_TEMPLATE_UID = 758; + private const STORY_SINGLE_PID = 26; + private const CONTACT_TARGET = '/contact'; + private const LEARNMORE_LABEL = 'Talk to our team'; + private const OLD_INSIGHTS_TARGET = '/success-stories'; + private const NEW_INSIGHTS_TARGET = '/insights/news'; + private const OLD_INSIGHTS_LABEL = 'Explore success stories'; + private const NEW_INSIGHTS_LABEL = 'Explore our insights'; + private const TESTIMONIALS_HEADER = 'Testimonials from the Field'; + private const GENERIC_PLACEHOLDER = 'placeholders/placeholder-grey.png'; + + private Connection $db; + private ResourceStorage $storage; + private OutputInterface $output; + private bool $apply = false; + + /** @var array */ + private array $counts = [ + 'images' => 0, 'targets' => 0, 'labels' => 0, 'logos' => 0, 'testimonials' => 0, + 'hiddenLeads' => 0, 'insightsTargets' => 0, 'insightsLabels' => 0, 'warnings' => 0, + ]; + + protected function configure(): void + { + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write the changes (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $this->output = $output; + $this->apply = (bool)$input->getOption('apply'); + $this->db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); + $this->storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + + $output->writeln($this->apply ? 'APPLY - writing via DataHandler' : 'DRY RUN - nothing is written (--apply to write)'); + $output->writeln(''); + + // --- template element for the logo band ------------------------------------ + $template = $this->db->fetchAssociative( + 'SELECT uid, pid, CType, header, hidden, deleted FROM tt_content WHERE uid = ?', + [self::LOGO_TEMPLATE_UID] + ); + if (!$template || $template['CType'] !== 'vitec_customerlogos' || (int)$template['deleted'] === 1) { + $output->writeln(sprintf('Logo template #%d is not a live vitec_customerlogos element.', self::LOGO_TEMPLATE_UID)); + return Command::FAILURE; + } + $templateSettings = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [self::LOGO_TEMPLATE_UID])); + $output->writeln(sprintf( + 'Logo template: #%d "%s" (page %d) - layout %s, customers %s, onlySelected %s', + $template['uid'], $template['header'], $template['pid'], + $templateSettings['layout'] ?? '-', ($templateSettings['customers'] ?? '') !== '' ? $templateSettings['customers'] : '(all)', $templateSettings['onlySelected'] ?? '0' + )); + + // --- the 11 market pages ------------------------------------------------------- + $markets = []; + foreach (self::MARKET_SLUGS as $slug) { + $page = $this->db->fetchAssociative( + 'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', + [$slug] + ); + if (!$page) { + $this->warn("page $slug NOT FOUND - skipped"); + continue; + } + $markets[(int)$page['uid']] = $page; + } + if ($markets === []) { + return Command::FAILURE; + } + $marketUids = array_keys($markets); + $in = implode(',', array_map('intval', $marketUids)); + + // --- sub-market testimonial lists (story selection for B3) ---------------------- + $flexFormService = GeneralUtility::makeInstance(FlexFormService::class); + $storiesByMarket = []; + $listFrequency = []; + $rows = $this->db->fetchAllAssociative( + "SELECT c.uid, c.pid, p.pid AS market, c.pi_flexform FROM tt_content c + JOIN pages p ON p.uid = c.pid + WHERE c.CType = 'vitec_usecaselist' AND c.deleted = 0 AND c.hidden = 0 AND c.sys_language_uid = 0 + AND p.deleted = 0 AND p.pid IN ($in) + ORDER BY p.sorting, c.sorting" + ); + foreach ($rows as $row) { + $settings = $this->flexSettings((string)$row['pi_flexform']); + $uids = array_values(array_filter(array_map('intval', explode(',', (string)($settings['usecases'] ?? ''))))); + if ($output->isVerbose()) { + $output->writeln(sprintf(' list #%d (page %d, market %d): layout=%s usecases=%d', $row['uid'], $row['pid'], $row['market'], $settings['layout'] ?? '-', count($uids))); + } + if (($settings['layout'] ?? '') !== 'testimonials' || $uids === []) { + continue; + } + $market = (int)$row['market']; + foreach ($uids as $uid) { + if (!in_array($uid, $storiesByMarket[$market] ?? [], true)) { + $storiesByMarket[$market][] = $uid; + } + } + $key = implode(',', $uids); + $listFrequency[$key] = ($listFrequency[$key] ?? 0) + 1; + } + arsort($listFrequency); + $defaultStories = $listFrequency === [] ? [] : array_map('intval', explode(',', (string)array_key_first($listFrequency))); + $output->writeln(sprintf( + 'Sub-market testimonial lists: %d element(s) on %d market(s); default list (most common, %dx): %s', + count($rows), count($storiesByMarket), $listFrequency === [] ? 0 : reset($listFrequency), implode(',', $defaultStories) + )); + $output->writeln(''); + + $purgePaths = []; + + // ===================================================================== + // main market pages + // ===================================================================== + foreach ($markets as $pid => $page) { + $output->writeln(sprintf('== %s (pid %d) "%s"', $page['slug'], $pid, $page['title'])); + $purgePaths[] = (string)$page['slug']; + + $elements = $this->db->fetchAllAssociative( + 'SELECT uid, CType, colPos, sorting, hidden, header, tx_container_parent, + vitec_learnmore_label, vitec_learnmore_target + FROM tt_content + WHERE pid = ? AND deleted = 0 AND sys_language_uid = 0 + ORDER BY colPos, sorting, uid', + [$pid] + ); + foreach ($elements as $el) { + $output->writeln(sprintf( + ' col %-3d %5d #%-6d %-22s %s%s', + $el['colPos'], $el['sorting'], $el['uid'], $el['CType'], + (int)$el['hidden'] === 1 ? '[hidden] ' : '', + $this->short((string)$el['header']) + )); + } + $topLevel = array_values(array_filter($elements, static fn(array $e): bool => (int)$e['tx_container_parent'] === 0)); + $live = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['hidden'] === 0)); + $col0 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 0)); + $col1 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 1)); + $col0All = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['colPos'] === 0)); + + $datamap = ['tt_content' => [], 'sys_file_reference' => []]; + + // --- B1: intro image + learn-more target -------------------------------- + $intro = $this->first($col0, 'vitec_marketintro'); + if ($intro === null) { + $this->warn('no live vitec_marketintro in colPos 0 - B1 skipped'); + } else { + $introUid = (int)$intro['uid']; + $refs = (int)$this->db->fetchOne( + "SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_image' AND uid_foreign = ?", + [$introUid] + ); + if ($refs > 0) { + $output->writeln(sprintf(' B1 intro #%d already has %d image(s) - unchanged', $introUid, $refs)); + } else { + $identifier = 'placeholders/pages/' . $pid . '-02.png'; + if (!$this->storage->hasFile($identifier)) { + $this->warn("$identifier missing - using generic " . self::GENERIC_PLACEHOLDER); + $identifier = self::GENERIC_PLACEHOLDER; + } + if (!$this->storage->hasFile($identifier)) { + $this->warn("$identifier missing as well - no image attached"); + } else { + $file = $this->storage->getFile($identifier); + $refId = 'NEWimg' . $pid; + $datamap['sys_file_reference'][$refId] = [ + 'pid' => $pid, + 'table_local' => 'sys_file', + 'uid_local' => $file->getUid(), + 'tablenames' => 'tt_content', + 'fieldname' => 'vitec_image', + 'uid_foreign' => $introUid, + ]; + $datamap['tt_content'][$introUid]['vitec_image'] = $refId; + $output->writeln(sprintf(' B1 intro #%d: attach %s (sys_file %d)', $introUid, $identifier, $file->getUid())); + $this->counts['images']++; + } + } + if (trim((string)$intro['vitec_learnmore_target']) === '') { + $datamap['tt_content'][$introUid]['vitec_learnmore_target'] = self::CONTACT_TARGET; + $output->writeln(sprintf(' B1 intro #%d: learnmore_target "" -> %s', $introUid, self::CONTACT_TARGET)); + $this->counts['targets']++; + if (trim((string)$intro['vitec_learnmore_label']) === '') { + $datamap['tt_content'][$introUid]['vitec_learnmore_label'] = self::LEARNMORE_LABEL; + $output->writeln(sprintf(' B1 intro #%d: learnmore_label "" -> %s', $introUid, self::LEARNMORE_LABEL)); + $this->counts['labels']++; + } + } else { + $output->writeln(sprintf(' B1 intro #%d: learnmore_target already "%s" - unchanged', $introUid, $intro['vitec_learnmore_target'])); + } + } + + // --- B5: hide the lead paragraph in the hero slot -------------------------- + $leads = array_values(array_filter($col1, static fn(array $e): bool => $e['CType'] === 'vitec_introparagraph')); + if (count($leads) !== 1) { + $this->warn(sprintf('%d live vitec_introparagraph in colPos 1 (expected 1)%s', count($leads), $leads === [] ? ' - B5 skipped' : ' - hiding all of them')); + } + foreach ($leads as $lead) { + $datamap['tt_content'][(int)$lead['uid']]['hidden'] = 1; + $output->writeln(sprintf(' B5 lead #%d "%s": hide', $lead['uid'], $this->short((string)$lead['header']))); + $this->counts['hiddenLeads']++; + } + + // --- anchor: last sub-market card after the "Explore" intro paragraph ----- + $anchorUid = 0; + $anchorLabel = ''; + $exploreIdx = null; + foreach ($col0 as $i => $el) { + if ($el['CType'] === 'vitec_introparagraph') { + $exploreIdx = $i; + break; + } + } + if ($exploreIdx !== null) { + $anchorUid = (int)$col0[$exploreIdx]['uid']; + $anchorLabel = 'intro paragraph #' . $anchorUid; + for ($i = $exploreIdx + 1; $i < count($col0) && $col0[$i]['CType'] === 'vitec_card'; $i++) { + $anchorUid = (int)$col0[$i]['uid']; + $anchorLabel = 'card #' . $anchorUid . ' "' . $this->short((string)$col0[$i]['header']) . '"'; + } + } elseif ($intro !== null) { + $anchorUid = (int)$intro['uid']; + $anchorLabel = 'market intro #' . $anchorUid; + } + + // the 2026-10-23 page import left HIDDEN shells for both bands - reuse them + $logosShell = $this->first($col0All, 'vitec_customerlogos'); + $testiShell = $this->first($col0All, 'vitec_usecaselist'); + $hasLogos = $logosShell !== null && (int)$logosShell['hidden'] === 0; + $hasTesti = $testiShell !== null && (int)$testiShell['hidden'] === 0; + $hasCta = $this->first($col0, 'vitec_ctabanner') !== null; + if (!$hasCta) { + $this->warn('no vitec_ctabanner in colPos 0 - the new bands will end the page'); + } + + // --- B2 (shell): unhide the "Trusted by" shell with the home page settings ----- + if ($logosShell !== null && !$hasLogos) { + $logosUid = (int)$logosShell['uid']; + $datamap['tt_content'][$logosUid] = [ + 'hidden' => 0, + 'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [ + 'settings.layout' => ['vDEF' => (string)($templateSettings['layout'] ?? 'marquee')], + 'settings.customers' => ['vDEF' => (string)($templateSettings['customers'] ?? '')], + 'settings.onlySelected' => ['vDEF' => (string)($templateSettings['onlySelected'] ?? '0')], + ]]]], + ]; + $output->writeln(sprintf(' B2 unhide logo shell #%d "%s", settings as #%d', $logosUid, $this->short((string)$logosShell['header']), self::LOGO_TEMPLATE_UID)); + $this->counts['logos']++; + } + + // --- B3: testimonials ------------------------------------------------------------- + // Selection: explicit lists of the market's sub-markets when they have + // any; today all 24 sub-market sliders have an EMPTY selection, which + // the plugin renders as "all stories" - the main pages do the same. + $stories = $storiesByMarket[$pid] ?? []; + $storySource = 'own sub-markets'; + if ($stories === []) { + $stories = $defaultStories; + $storySource = 'most common sub-market list'; + } + if ($stories === []) { + $storySource = 'empty selection = all stories, like the sub-market pages'; + } + if ($hasTesti) { + $output->writeln(' B3 vitec_usecaselist already on the page - unchanged'); + } elseif ($testiShell !== null) { + $testiUid = (int)$testiShell['uid']; + $current = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [$testiUid])); + $currentStories = array_values(array_filter(array_map('intval', explode(',', (string)($current['usecases'] ?? ''))))); + if ($currentStories !== []) { + $stories = $currentStories; + $storySource = 'already selected on the shell'; + } + { + $datamap['tt_content'][$testiUid] = [ + 'hidden' => 0, + 'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [ + 'settings.layout' => ['vDEF' => 'testimonials'], + 'settings.usecases' => ['vDEF' => implode(',', $stories)], + 'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID], + 'settings.showtoolbar' => ['vDEF' => '0'], + ]]]], + ]; + $output->writeln(sprintf(' B3 unhide shell #%d "%s" as testimonials (%s%s)', $testiUid, $this->short((string)$testiShell['header']), $storySource, $stories === [] ? '' : ': ' . implode(',', $stories))); + $this->counts['testimonials']++; + } + } elseif ($anchorUid === 0) { + $this->warn('no anchor element found - B2/B3 skipped'); + } else { + $datamap['tt_content']['NEWtesti' . $pid] = [ + 'pid' => -$anchorUid, + 'colPos' => 0, + 'sys_language_uid' => 0, + 'CType' => 'vitec_usecaselist', + 'header' => self::TESTIMONIALS_HEADER, + 'pi_flexform' => [ + 'data' => ['sDEF' => ['lDEF' => [ + 'settings.layout' => ['vDEF' => 'testimonials'], + 'settings.usecases' => ['vDEF' => implode(',', $stories)], + 'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID], + 'settings.showtoolbar' => ['vDEF' => '0'], + ]]], + ], + ]; + $output->writeln(sprintf(' B3 new vitec_usecaselist (testimonials, %s%s) after %s', $storySource, $stories === [] ? '' : ': ' . implode(',', $stories), $anchorLabel)); + $this->counts['testimonials']++; + } + + // --- write datamap ------------------------------------------------------------ + $this->runDataHandler($datamap, []); + + // --- B2 (no shell): logo band copied from #758, after the anchor (= before testimonials) + if ($hasLogos) { + $output->writeln(' B2 vitec_customerlogos already visible on the page - unchanged'); + } elseif ($logosShell !== null) { + // handled above (shell unhidden in the datamap) + } elseif ($anchorUid > 0) { + $cmdmap = ['tt_content' => [self::LOGO_TEMPLATE_UID => ['copy' => [ + 'action' => 'paste', + 'target' => -$anchorUid, + 'update' => ['colPos' => 0], + ]]]]; + $output->writeln(sprintf(' B2 copy logo band #%d after %s', self::LOGO_TEMPLATE_UID, $anchorLabel)); + $this->counts['logos']++; + $this->runDataHandler([], $cmdmap); + } + $output->writeln(''); + } + + // ===================================================================== + // sub-market pages: insights CTA -> news + // ===================================================================== + $output->writeln('== sub-market pages: vitec_marketinsights CTA'); + $insights = $this->db->fetchAllAssociative( + "SELECT c.uid, c.pid, c.hidden, c.vitec_cta_label, c.vitec_cta_target, p.slug, p.hidden AS page_hidden + FROM tt_content c JOIN pages p ON p.uid = c.pid + WHERE c.CType = 'vitec_marketinsights' AND c.deleted = 0 AND c.sys_language_uid = 0 + AND p.deleted = 0 AND p.pid IN ($in) + ORDER BY p.slug" + ); + $datamap = ['tt_content' => []]; + foreach ($insights as $row) { + $uid = (int)$row['uid']; + $target = trim((string)$row['vitec_cta_target']); + $label = trim((string)$row['vitec_cta_label']); + $changes = []; + if ($target === self::OLD_INSIGHTS_TARGET || $target === '') { + $datamap['tt_content'][$uid]['vitec_cta_target'] = self::NEW_INSIGHTS_TARGET; + $changes[] = sprintf('target "%s" -> %s', $target, self::NEW_INSIGHTS_TARGET); + $this->counts['insightsTargets']++; + if ($label === self::OLD_INSIGHTS_LABEL || $label === '') { + $datamap['tt_content'][$uid]['vitec_cta_label'] = self::NEW_INSIGHTS_LABEL; + $changes[] = sprintf('label "%s" -> %s', $label, self::NEW_INSIGHTS_LABEL); + $this->counts['insightsLabels']++; + } else { + $changes[] = sprintf('label "%s" kept (not the standard text)', $label); + } + $purgePaths[] = (string)$row['slug']; + } elseif ($target === self::NEW_INSIGHTS_TARGET) { + $changes[] = 'already ' . self::NEW_INSIGHTS_TARGET; + } else { + $changes[] = sprintf('target "%s" is individual - unchanged', $target); + } + $output->writeln(sprintf(' #%-6d %-60s %s%s', $uid, $row['slug'], (int)$row['hidden'] === 1 || (int)$row['page_hidden'] === 1 ? '[hidden] ' : '', implode('; ', $changes))); + } + $this->runDataHandler($datamap, []); + $output->writeln(''); + + // ===================================================================== + $output->writeln(sprintf( + '%s: %d intro image(s), %d learnmore target(s) (+%d label), %d logo band(s), %d testimonial slider(s), %d lead paragraph(s) hidden, %d insights target(s) (+%d label); %d warning(s).', + $this->apply ? 'DONE' : 'DRY RUN - would write', + $this->counts['images'], $this->counts['targets'], $this->counts['labels'], $this->counts['logos'], $this->counts['testimonials'], + $this->counts['hiddenLeads'], $this->counts['insightsTargets'], $this->counts['insightsLabels'], $this->counts['warnings'] + )); + $purgePaths = array_values(array_unique($purgePaths)); + $output->writeln('Afterwards: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge ' . implode(' ', $purgePaths) . ' && vendor/bin/typo3 vitec:ssr-warm ' . implode(' ', $purgePaths)); + return Command::SUCCESS; + } + + /** @param array>> $datamap */ + private function runDataHandler(array $datamap, array $cmdmap): void + { + $datamap = array_filter($datamap); + if (!$this->apply || ($datamap === [] && $cmdmap === [])) { + return; + } + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, $cmdmap); + if ($datamap !== []) { + $dataHandler->process_datamap(); + } + if ($cmdmap !== []) { + $dataHandler->process_cmdmap(); + } + if ($dataHandler->errorLog !== []) { + $this->warn('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); + } + foreach ($dataHandler->substNEWwithIDs as $new => $uid) { + $this->output->writeln(sprintf(' written: %s -> #%d', $new, $uid)); + } + foreach ($dataHandler->copyMappingArray_merged['tt_content'] ?? [] as $from => $to) { + $this->output->writeln(sprintf(' copied: #%d -> #%d', $from, $to)); + } + } + + /** @return array flat "settings" sheet of a pi_flexform XML (empty when none) */ + 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) : []; + } + + /** @param array> $elements */ + private function first(array $elements, string $cType): ?array + { + foreach ($elements as $el) { + if ($el['CType'] === $cType) { + return $el; + } + } + return null; + } + + private function warn(string $message): void + { + $this->counts['warnings']++; + $this->output->writeln(' WARN ' . $message . ''); + } + + private function short(string $text): string + { + $text = trim((string)preg_replace('/\s+/', ' ', strip_tags($text))); + return mb_strlen($text) > 60 ? mb_substr($text, 0, 57) . '...' : $text; + } +}