Files
VITEC-website/packages/vitec/Classes/Command/MarketsFigmaElementsCommand.php
T
orascheandClaude Fable 5.1 47c8e2902f 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/<uid>-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 <noreply@anthropic.com>
2026-11-12 19:14:05 +01:00

511 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Ticket 0124-FE (2026-11-12): Joe aligned the market pages with Figma, the
* front end renders the new bands (0118-FE) - this command creates the
* content they need. Per main market page (/markets/<slug>, 11 pages):
*
* B1 vitec_marketintro: numbered placeholder image placeholders/pages/<uid>-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<string,int> */
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 ? '<comment>APPLY - writing via DataHandler</comment>' : '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('<error>Logo template #%d is not a live vitec_customerlogos element.</error>', 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('<info>== %s (pid %d) "%s"</info>', $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('<info>== sub-market pages: vitec_marketinsights CTA</info>');
$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<string,array<string|int,array<string,mixed>>> $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<string,string> 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<int,array<string,mixed>> $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(' <comment>WARN ' . $message . '</comment>');
}
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;
}
}