From 46b12d4a589840951d8ed887a86816c8aceca3e3 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Wed, 4 Nov 2026 19:55:01 +0100 Subject: [PATCH] product.features: Why-choose cards with icons (contract 0036-FE) - new inline table tx_vitec_product_feature (title, cardtext, FAL icon) + `features` field on the product record; ProductShowJsonRenderer emits product.features {heading, items[{title, text, icon}]} and null while a product has no cards, so the front end keeps its applications-HTML fallback - new vitec:seed-product-features: seeds the cards from the applications

pairs and matches the staged agency icons by normalised title (camelCase split, stop words, Levenshtein <= 3 typo tolerance); products with existing cards are never touched - new vitec:build-why-vitec-bands: "Why VITEC?" intro + icon_card band on the 7 overview pages with a staged icon set (the contract's "centered" variant does not exist - documented in ticket 0039-BE); vision/visionav sub-group sets stay unassigned - product-check page: sortable title column and an Open Product button - new migration fix_colombia_quote.php: stray quote element #12037 removed, quote_role set (ticket 0038-FE) Co-Authored-By: Claude Fable 5 --- migrations/fix_colombia_quote.php | 81 +++++ .../Command/BuildWhyVitecBandsCommand.php | 206 ++++++++++++ .../Command/SeedProductFeaturesCommand.php | 298 ++++++++++++++++++ .../UserFunc/ProductShowJsonRenderer.php | 66 ++++ .../TCA/tx_vitec_domain_model_product.php | 23 +- .../TCA/tx_vitec_product_feature.php | 85 +++++ packages/vitec/ext_tables.sql | 18 ++ public/product-check/index.php | 26 +- 8 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 migrations/fix_colombia_quote.php create mode 100644 packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php create mode 100644 packages/vitec/Classes/Command/SeedProductFeaturesCommand.php create mode 100644 packages/vitec/Configuration/TCA/tx_vitec_product_feature.php diff --git a/migrations/fix_colombia_quote.php b/migrations/fix_colombia_quote.php new file mode 100644 index 0000000..a75c176 --- /dev/null +++ b/migrations/fix_colombia_quote.php @@ -0,0 +1,81 @@ + PDO::ERRMODE_EXCEPTION] +); + +$role = 'Director of the Engineering Department of Cosmos Sistemas Integrado'; + +echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n"; + +$story = $pdo->query( + "SELECT uid, title, quote_person, quote_role, content_elements FROM tx_vitec_domain_model_usecase + WHERE deleted = 0 AND slug = 'the-national-police-of-colombia'" +)->fetch(PDO::FETCH_ASSOC); +if (!$story) { + echo "ABBRUCH: Story the-national-police-of-colombia nicht gefunden.\n"; + exit(1); +} +printf( + "Story #%d \"%s\": quote_person=\"%s\" quote_role=\"%s\" content_elements=%d\n", + $story['uid'], $story['title'], $story['quote_person'], $story['quote_role'], $story['content_elements'] +); + +$element = $pdo->query( + 'SELECT uid, pid, CType, deleted, tx_vitec_usecase_content, bodytext, header FROM tt_content WHERE uid = 12037' +)->fetch(PDO::FETCH_ASSOC); +if (!$element) { + echo "Element #12037 existiert nicht.\n"; + exit(1); +} +printf( + "Element #12037: CType=%s deleted=%d usecase=%d text=\"%s\"\n", + $element['CType'], $element['deleted'], $element['tx_vitec_usecase_content'], + mb_substr(trim(strip_tags((string)$element['bodytext'] . ' ' . (string)$element['header'])), 0, 90) +); +if ((int)$element['tx_vitec_usecase_content'] !== (int)$story['uid']) { + echo "ABBRUCH: Element #12037 haengt nicht an dieser Story.\n"; + exit(1); +} +if ((int)$element['deleted'] === 1) { + echo "Element ist bereits geloescht.\n"; +} + +if (!$apply) { + echo "\nWuerde: Element #12037 loeschen, content_elements neu zaehlen, quote_role setzen auf:\n \"$role\"\n"; + exit(0); +} + +if ((int)$element['deleted'] === 0) { + $pdo->prepare('UPDATE tt_content SET deleted = 1, tstamp = ? WHERE uid = 12037')->execute([time()]); + echo "Element #12037 geloescht.\n"; +} +$count = (int)$pdo->query( + 'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = ' . (int)$story['uid'] +)->fetchColumn(); +$pdo->prepare('UPDATE tx_vitec_domain_model_usecase SET content_elements = ?, quote_role = ?, tstamp = ? WHERE uid = ?') + ->execute([$count, $role, time(), (int)$story['uid']]); +printf("Record aktualisiert: quote_role gesetzt, content_elements = %d.\n", $count); +echo "Danach: vendor/bin/typo3 cache:flush\n"; diff --git a/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php b/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php new file mode 100644 index 0000000..57ed017 --- /dev/null +++ b/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php @@ -0,0 +1,206 @@ +, contract 0036-FE variant 3b): one vitec_introparagraph + * headline plus one vitec_card (icon_card variant - the design's "centered" + * card; 0036 named a variant that does not exist) per staged agency icon of + * that page's context under fileadmin/icons/delivered/products//. + * Card titles are derived from the icon filename theme ("ScalableArchitecture" + * -> "Scalable architecture"); bodytext stays empty until the agency + * delivers copy (0036: a card without text is fine). + * + * The band lands directly AFTER the first element of the main column (the + * intro). Idempotent: a page already carrying a "Why VITEC?" introparagraph + * is skipped - editors own the band from then on. The Vision sub-group icon + * sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial + * decision, reported only. + * + * vendor/bin/typo3 vitec:build-why-vitec-bands + * vendor/bin/typo3 vitec:build-why-vitec-bands --apply + */ +#[AsCommand( + name: 'vitec:build-why-vitec-bands', + description: 'Create the Why-VITEC icon bands on the product overview pages from the staged icons (dry-run by default)' +)] +final class BuildWhyVitecBandsCommand extends Command +{ + private const ICON_BASE = 'icons/delivered/products/'; + private const HEADER = 'Why VITEC?'; + private const COLPOS = 0; + + /** staged icon context => overview page slug */ + private const BANDS = [ + 'activesqx' => '/products/activesqx', + 'aetria' => '/products/aetria', + 'aligo' => '/products/aligo', + 'avedia-modular-system' => '/products/avedia-modular-system', + 'image-graphics-cards' => '/products/image-graphics-cards', + 'operator-workstations' => '/products/operator-workstations', + 'prism-transcoder' => '/products/prism-transcoder', + ]; + + /** acronyms kept upper-case when the filename theme becomes a card title */ + private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI']; + + private int $newCounter = 0; + + protected function configure(): void + { + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $apply = (bool)$input->getOption('apply'); + $pool = GeneralUtility::makeInstance(ConnectionPool::class); + $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count(self::BANDS) . ' band(s)'); + + $built = 0; + foreach (self::BANDS as $context => $slug) { + $page = $pool->getConnectionForTable('pages')->fetchAssociative( + 'SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', + [$slug] + ); + if (!$page) { + $output->writeln('' . $slug . ': page not found - skipped'); + continue; + } + $pageUid = (int)$page['uid']; + + $existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne( + "SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?", + [$pageUid, self::HEADER] + ); + if ($existing > 0) { + $output->writeln($slug . ': band exists - untouched'); + continue; + } + + $folder = self::ICON_BASE . $context; + $icons = []; + if ($storage->hasFolder($folder)) { + foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) { + if (mb_strtolower($file->getExtension()) === 'svg' && preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) { + $icons[] = ['identifier' => $file->getIdentifier(), 'title' => $this->themeTitle($m[1])]; + } + } + } + if ($icons === []) { + $output->writeln('' . $slug . ': no staged icons under fileadmin/' . $folder . ' - skipped'); + continue; + } + + $output->writeln(sprintf("\n%s (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons))); + foreach ($icons as $icon) { + $output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')'); + } + $built++; + if (!$apply) { + continue; + } + + // current main-column order (top-level elements only: the band + // must not land inside a b13 container) + $elements = $pool->getConnectionForTable('tt_content')->fetchAllAssociative( + 'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? + AND (tx_container_parent IS NULL OR tx_container_parent = 0) + ORDER BY sorting, uid', + [$pageUid, self::COLPOS] + ); + $existingUids = array_map(static fn(array $r): string => (string)(int)$r['uid'], $elements); + + $datamap = []; + $newIds = []; + $introId = $this->newId(); + $datamap['tt_content'][$introId] = [ + 'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_introparagraph', + 'header' => self::HEADER, 'bodytext' => '', + ]; + $newIds[] = $introId; + foreach ($icons as $icon) { + $refId = $this->newId(); + $datamap['sys_file_reference'][$refId] = [ + 'pid' => $pageUid, + 'table_local' => 'sys_file', + 'uid_local' => $storage->getFile($icon['identifier'])->getUid(), + ]; + $cardId = $this->newId(); + $datamap['tt_content'][$cardId] = [ + 'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_card', + 'header' => $icon['title'], 'bodytext' => '', + 'vitec_layout_variant' => 'icon_card', + 'vitec_icon' => $refId, + ]; + $newIds[] = $cardId; + } + + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, []); + $dataHandler->process_datamap(); + if ($dataHandler->errorLog !== []) { + $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); + return Command::FAILURE; + } + + // final order: first element (intro), then the band, then the rest + $order = array_merge( + array_slice($existingUids, 0, 1), + array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds), + array_slice($existingUids, 1) + ); + $sorting = 0; + foreach ($order as $uid) { + if ((int)$uid > 0) { + $sorting += 256; + $pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => (int)$uid]); + } + } + } + + $output->writeln(sprintf( + "\n%d band(s) %s. Vision sub-group icon sets (vision, visionav) stay unassigned - one page, two sets (editorial).", + $built, $apply ? 'written - flush the cache' : 'planned (dry run - nothing written)' + )); + return Command::SUCCESS; + } + + /** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */ + private function themeTitle(string $theme): string + { + $words = preg_split('/(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)/', $theme, -1, PREG_SPLIT_NO_EMPTY) ?: [$theme]; + $out = []; + foreach ($words as $i => $word) { + if (in_array(strtoupper($word), self::ACRONYMS, true)) { + $out[] = strtoupper($word); + } elseif (preg_match('/\d/', $word)) { + $out[] = $word; + } else { + $out[] = $i === 0 ? ucfirst(mb_strtolower($word)) : mb_strtolower($word); + } + } + return implode(' ', $out); + } + + private function newId(): string + { + return 'NEWWV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); + } +} diff --git a/packages/vitec/Classes/Command/SeedProductFeaturesCommand.php b/packages/vitec/Classes/Command/SeedProductFeaturesCommand.php new file mode 100644 index 0000000..2981d4a --- /dev/null +++ b/packages/vitec/Classes/Command/SeedProductFeaturesCommand.php @@ -0,0 +1,298 @@ +?" cards (tx_vitec_product_feature, + * contract 0036-FE) from the `applications` RTE HTML plus the staged agency + * icons: + * + * - `applications` pairs `

Title

Text

` become + * one feature record each (several text paragraphs are joined); the + * leading

stays where it is - the JSON heading is derived from it. + * - icons come from fileadmin/icons/delivered/products// (the + * 2026-11-03 staging); is the product slug unless ICON_DIRS + * maps it. Matching: normalised title vs. the filename theme (the part + * after the last "-"), stop words removed, Levenshtein <= 3 as typo + * tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard"). + * - a product that already HAS feature records is never touched - after + * the seed the editors own the cards. + * + * Unmatched icons and unmatched cards are reported so the editors can map + * the leftovers by hand (0036-FE asks for exactly that list). + * + * vendor/bin/typo3 vitec:seed-product-features + * vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply + */ +#[AsCommand( + name: 'vitec:seed-product-features', + description: 'Seed the Why-choose feature cards from the applications HTML + staged icons (dry-run by default)' +)] +final class SeedProductFeaturesCommand extends Command +{ + private const TABLE = 'tx_vitec_product_feature'; + private const ICON_BASE = 'icons/delivered/products/'; + + /** product slug => staging context where the names differ */ + private const ICON_DIRS = [ + 'avedia-platform-iptv-signage-cmsserver' => 'avedia-platform', + 'ez-tv-platform-iptv-signage-cmsserver' => 'ez-tv-platform', + 'x-player-end-points-xp23-xp25-xp26' => 'x-player-end-points-product', + ]; + + private const STOPWORDS = ['and', 'or', 'the', 'a', 'an', 'of', 'on', 'in', 'to', 'with', 'for', 'your', 'at', 'is']; + + private int $newCounter = 0; + + protected function configure(): void + { + $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring'); + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $apply = (bool)$input->getOption('apply'); + $only = mb_strtolower((string)($input->getOption('only') ?? '')); + $pool = GeneralUtility::makeInstance(ConnectionPool::class); + + $products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative( + 'SELECT uid, pid, title, slug, applications FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title)' + ); + $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($products) . ' product(s)'); + + $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + $seeded = 0; + $cards = 0; + $iconsMatched = 0; + $skippedExisting = 0; + $skippedEmpty = 0; + + foreach ($products as $product) { + $uid = (int)$product['uid']; + $slug = (string)$product['slug']; + if ($only !== '' && !str_contains(mb_strtolower($slug), $only)) { + continue; + } + + $existing = (int)$pool->getConnectionForTable(self::TABLE)->fetchOne( + 'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?', + [$uid] + ); + if ($existing > 0) { + $skippedExisting++; + continue; + } + + $pairs = $this->parseApplications((string)$product['applications']); + if ($pairs === []) { + $skippedEmpty++; + continue; + } + + // staged icons of this product, filename theme => identifier + $context = self::ICON_DIRS[$slug] ?? $slug; + $iconByTheme = []; + $folder = self::ICON_BASE . $context; + if ($storage->hasFolder($folder)) { + foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) { + if (mb_strtolower($file->getExtension()) !== 'svg') { + continue; + } + if (preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) { + $iconByTheme[$m[1]] = $file->getIdentifier(); + } + } + } + + $output->writeln(sprintf("\n%s (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme))); + + // match icons onto the pairs + $usedThemes = []; + foreach ($pairs as $i => $pair) { + $theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes)); + if ($theme !== null) { + $pairs[$i]['icon'] = $iconByTheme[$theme]; + $usedThemes[$theme] = true; + } + } + + foreach ($pairs as $pair) { + $output->writeln(sprintf( + ' "%s"%s%s', + $pair['title'], + $pair['text'] !== '' ? '' : ' (no text)', + isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' - no icon' + )); + } + foreach (array_diff_key($iconByTheme, $usedThemes) as $theme => $identifier) { + $output->writeln(' UNMATCHED icon: ' . basename($identifier) . ''); + } + + $cards += count($pairs); + $iconsMatched += count($usedThemes); + $seeded++; + + if (!$apply) { + continue; + } + + $datamap = []; + $featureIds = []; + foreach ($pairs as $pair) { + $featureId = $this->newId(); + $record = [ + 'pid' => (int)$product['pid'], + 'product' => $uid, + 'title' => $pair['title'], + 'cardtext' => $pair['text'], + ]; + if (isset($pair['icon'])) { + $refId = $this->newId(); + $datamap['sys_file_reference'][$refId] = [ + 'pid' => (int)$product['pid'], + 'table_local' => 'sys_file', + 'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(), + ]; + $record['icon'] = $refId; + } + $datamap[self::TABLE][$featureId] = $record; + $featureIds[] = $featureId; + } + $datamap['tx_vitec_domain_model_product'][$uid] = ['features' => implode(',', $featureIds)]; + + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, []); + $dataHandler->process_datamap(); + if ($dataHandler->errorLog !== []) { + $output->writeln(' DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); + return Command::FAILURE; + } + // DataHandler puts NEW records at the TOP - write explicit sorting + $sorting = 0; + foreach ($featureIds as $featureId) { + $realUid = (int)($dataHandler->substNEWwithIDs[$featureId] ?? 0); + if ($realUid > 0) { + $sorting += 256; + $pool->getConnectionForTable(self::TABLE)->update(self::TABLE, ['sorting' => $sorting], ['uid' => $realUid]); + } + } + } + + $output->writeln(sprintf( + "\n%d product(s) seeded (%d cards, %d icons matched), %d with existing cards untouched, %d without applications pairs%s", + $seeded, $cards, $iconsMatched, $skippedExisting, $skippedEmpty, + $apply ? '' : ' (dry run - nothing written)' + )); + if ($apply && $seeded > 0) { + $output->writeln('Flush the cache: vendor/bin/typo3 cache:flush'); + } + return Command::SUCCESS; + } + + /** + * The `

Title

Text…

` pairs of the + * applications HTML, in order. Several text paragraphs after one title + * are joined; the

heading and empty paragraphs are skipped. + * + * @return array + */ + private function parseApplications(string $html): array + { + if (trim($html) === '') { + return []; + } + $pairs = []; + $current = null; + if (!preg_match_all('#]*>(.*?)

#is', $html, $matches)) { + return []; + } + foreach ($matches[1] as $inner) { + $plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); + if ($plain === '') { + continue; + } + $strongOnly = preg_match('#^\s*]*>(.*?)
\s*$#is', trim($inner), $m) + ? trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'))) + : null; + if ($strongOnly !== null && $strongOnly !== '') { + if ($current !== null) { + $pairs[] = $current; + } + $current = ['title' => $strongOnly, 'text' => '']; + continue; + } + if ($current !== null) { + $current['text'] = trim($current['text'] . ' ' . $plain); + } + } + if ($current !== null) { + $pairs[] = $current; + } + return $pairs; + } + + /** + * The icon theme that matches a card title: exact on the normalised + * strings (stop words removed on both sides), else the closest theme + * within Levenshtein distance 3 (typo tolerance). + * + * @param array $iconByTheme theme => identifier + */ + private function matchTheme(string $title, array $iconByTheme): ?string + { + if ($iconByTheme === []) { + return null; + } + $titleNorms = array_unique([$this->norm($title, false), $this->norm($title, true)]); + $best = null; + $bestDistance = PHP_INT_MAX; + foreach (array_keys($iconByTheme) as $theme) { + $themeNorms = array_unique([$this->norm($theme, false), $this->norm($theme, true)]); + foreach ($titleNorms as $a) { + foreach ($themeNorms as $b) { + if ($a === $b) { + return $theme; + } + if (abs(strlen($a) - strlen($b)) <= 3) { + $distance = levenshtein($a, $b); + if ($distance < $bestDistance) { + $bestDistance = $distance; + $best = $theme; + } + } + } + } + } + return $bestDistance <= 3 ? $best : null; + } + + /** lowercase, letters/digits only; optionally with stop words removed (camelCase themes are split first) */ + private function norm(string $value, bool $dropStopwords): string + { + $words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; + if ($dropStopwords) { + $words = array_filter($words, static fn(string $w): bool => !in_array(mb_strtolower($w), self::STOPWORDS, true)); + } + return mb_strtolower(implode('', $words)); + } + + private function newId(): string + { + return 'NEWPF' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); + } +} diff --git a/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php index 6c20c58..94f9ce5 100755 --- a/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php @@ -258,6 +258,11 @@ class ProductShowJsonRenderer 'resources' => $this->resourcesBand($uid, (string)(($product['resources_layout'] ?? '') ?: '50-50')), ]; + // "Why choose ?" cards (contract 0036-FE): null while the + // product has no feature records - the front end then keeps parsing + // the `applications` HTML above. + $data['features'] = $this->featuresBand($uid, (string)$data['applications'], (string)$data['title']); + // schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit // @graph string. The frontend injects it as