diff --git a/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php b/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php index 167a2d0..aa92742 100644 --- a/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php @@ -28,6 +28,9 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; * "numFound": total hits, * "totalPages": ceil(numFound / resultsPerPage), * "results": [ { "title", "url", "type", "teaser" } ], + * "filter": the active type filter or null, + * "facets": { "type": [ { "value", "count", "active" } ] } - + * counts stay complete while a filter is active, * "suggestions": spellcheck alternatives ("did you mean"), [] if none * } * @@ -35,6 +38,8 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; * q = search terms; without it the empty shape above is returned * (numFound 0) so the frontend always sees the same structure * page = 1-based page number, optional + * filter = restrict results to one type from the `type` vocabulary + * (page|product|market|story|news|download); unknown values ignored * The EXT:solr namespace (tx_solr[q], tx_solr[page]) is accepted as a * fallback so classic solr URLs keep working. * @@ -132,13 +137,21 @@ class SearchJsonRenderer // excluded from cHash - cached variants would collide (config.no_cache // is gone in TYPO3 v14, so the cache is disabled per request here). $request->getAttribute('frontend.cache.instruction') - ?->disableCache('vitec search: response varies by q/page query parameters'); + ?->disableCache('vitec search: response varies by q/page/filter query parameters'); $params = $request->getQueryParams(); $solrNamespace = (array)($params['tx_solr'] ?? []); $query = trim((string)($params['q'] ?? $solrNamespace['q'] ?? '')); $page = max(1, (int)($params['page'] ?? $solrNamespace['page'] ?? 1)); + // Optional type filter, friendly vocabulary (see TYPE_LABELS). + // Unknown values are ignored, never an error. + $activeType = trim((string)($params['filter'] ?? '')); + $typeFilterField = array_search($activeType, self::TYPE_LABELS, true); + if ($typeFilterField === false) { + $activeType = ''; + } + if ($query === '') { return (string)json_encode($this->emptyResult()); } @@ -163,8 +176,12 @@ class SearchJsonRenderer $typoScriptConfiguration, $search, ); + $arguments = ['q' => $query, 'page' => $page]; + if ($activeType !== '') { + $arguments['filter'] = ['type:' . $typeFilterField]; + } $searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration) - ->buildForSearch(['q' => $query, 'page' => $page], $pageId, $languageId); + ->buildForSearch($arguments, $pageId, $languageId); $resultSet = $searchService->search($searchRequest); @@ -205,6 +222,29 @@ class SearchJsonRenderer // spellchecking disabled or unavailable - not essential } + $facets = []; + try { + foreach ($resultSet->getFacets() as $facet) { + $options = []; + foreach ($facet->getOptions() as $option) { + $value = (string)$option->getUriValue(); + if ($facet->getName() === 'type') { + $value = self::TYPE_LABELS[$value] ?? $value; + } + $options[] = [ + 'value' => $value, + 'count' => $option->getDocumentCount(), + 'active' => $option->getSelected(), + ]; + } + if ($options !== []) { + $facets[$facet->getName()] = $options; + } + } + } catch (\Throwable) { + // faceting disabled or unavailable - facets stay empty + } + $numFound = $resultSet->getAllResultCount(); $resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results); @@ -214,6 +254,8 @@ class SearchJsonRenderer 'resultsPerPage' => $resultsPerPage, 'numFound' => $numFound, 'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0, + 'filter' => $activeType !== '' ? $activeType : null, + 'facets' => (object)$facets, 'results' => $results, 'suggestions' => array_values(array_unique($suggestions)), ]); @@ -233,6 +275,8 @@ class SearchJsonRenderer 'resultsPerPage' => 0, 'numFound' => 0, 'totalPages' => 0, + 'filter' => null, + 'facets' => new \stdClass(), 'results' => [], 'suggestions' => [], ]; diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index d54f473..497b9ec 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -533,3 +533,19 @@ plugin.tx_solr.index.queue { } } } + +# Type facet for the search JSON: counts per document type, serialized by +# SearchJsonRenderer as content.search.facets.type. keepAll* keeps the full +# counts visible while one type is selected - otherwise the tabs would +# collapse to the active one. +plugin.tx_solr.search.faceting = 1 +plugin.tx_solr.search.faceting { + keepAllFacetsOnSelection = 1 + facets { + type { + label = Type + field = type + keepAllOptionsOnSelection = 1 + } + } +} diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md index a17749f..789f759 100755 --- a/packages/vitec/Documentation/Headless-JSON-Architecture.md +++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md @@ -3,9 +3,9 @@ | | | |---|---| | **Document identifier** | EVO‑VITEC‑HL‑001 | -| **Version** | 1.9 | +| **Version** | 1.12 | | **Status** | Released | -| **Date** | 2026‑08‑15 | +| **Date** | 2026‑08‑24 | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | | **Owner** | evomedien — VITEC relaunch | @@ -24,6 +24,8 @@ | 1.8 | 2026‑08‑13 | **Defect fix and interface change (additive).** Content Blocks never carried the Core *Appearance* tab: `layout`, `frame_class` — including the VITEC frame classes — `space_before_class`, `space_after_class`, `sectionIndex` and `linkToTop` were unreachable for editors on all nine blocks. Added centrally for every `vitec_*` type (7.7); the `appearance` envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside `data` on top‑level blocks (B‑13), and `appearance.layout` is represented differently on the two envelope paths (B‑14). `intro-paragraph` gained `background_color` (7.7). `vitec_eventlist` gained the layout `regions`, emitting a `regions` array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B‑11 and B‑12 recorded as resolved. | | 1.9 | 2026‑08‑15 | **Interface change, partly breaking.** The four list plugins were unified: every one of them now emits an object carrying `layout`, the new `showToolbar` flag and its payload array. `vitec_usecaselist` and `vitec_productlist` previously emitted a **bare array** — front ends reading them have to move one level down (7.3, 7.14, 8). New plugin **`vitec_solutionlist`** (`SolutionListJsonRenderer`, key `solutions`), the counterpart 7.13.2 had been asking for since v1.2. `tx_vitec_domain_model_usecase` gained `detail_page`, emitted as the resolved `detailUrl` in the story card shape and therefore also in the `vitec_modelcard` story branch, which had carried no link at all until now. Noted: `vitec_productlist` had a configurable `layout` that was never serialised, and its vocabulary (`0`–`3`) differs from the other lists. | | 1.10 | 2026‑08‑18 | **Interface change (additive).** `tx_vitec_domain_model_product` gained five fields: `heroimage` (multiple FAL images, detail payload only), the richtext fields `description2`, `capabilities` and `textrelatedproducts`, and `portfolio` (TCA `link`) — emitted as a **resolved URL** through the new `LinkResolver::typolinkUrl()`. The product payloads are specified for the first time (7.15). Links in `contentelement` / `contentelementcta` that point at a **container** now resolve its children (`items`, page‑level shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved server‑side per the new Clause 9.12; new middleware `vitec/download-file` streams `/download/file/` as a forced download (5.3), file lookup consolidated into `DownloadFileResolver` — first step towards the B‑4 target (10.2). Backend‑only: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. | +| 1.11 | 2026‑08‑21 | **New interface: site search.** Apache Solr 10 (dedicated VPS behind an HTTPS reverse proxy) with `apache-solr-for-typo3/solr` 14.0.0-RC1. The EXT:solr results plugin `solr_pi_results` on the search page is rendered headless by `SearchJsonRenderer` (payload key `search`) - request/response contract in the new Clause 7.16. Indexed corpus: pages plus product, market (`detail_page` only), use-case, news and download records; result `type` vocabulary `page\|product\|market\|story\|news\|download`. Downloads gained the canonical route `/download/` (uid route kept for the record links) and `private_download` is now enforced by `DownloadFileResolver` (5.3, 9.12). The VITEC Set now declares the solr set as a dependency - overriding a foreign set’s TypoScript requires loading after it (5.2). Editorial: the header table had been stuck at v1.9 since v1.10. | +| 1.12 | 2026‑08‑24 | **Interface change (additive).** The search endpoint (7.16) gained a type filter and facet counts: request parameter `filter` (a value from the `type` vocabulary; the natural name `type` is unavailable - it is TYPO3’s reserved page-type parameter), response keys `filter` (active filter or null) and `facets.type` (per-type document counts with `active` flags; counts stay complete while a filter is active, except when the filtered result is empty). Editorial baseline of 76 managed synonyms imported into `core_en` (codecs, acquired-brand names such as `exterity => avedia`, UK/US spellings, common misspellings) - synonyms apply at query time, no re-index. | This document is drafted in the style of, and adopts the terminology conventions of, ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for @@ -153,10 +155,13 @@ they normalise a raw `tt_content` row into an envelope and resolve nested elemen | `netzbewegung/nb-headless-content-blocks` | ^0.0.23 | Content Blocks → JSON | | `b13/container` | ^3.1 | Nested column containers | | `georgringer/news` | ^14.0 | News records and plugins | +| `apache-solr-for-typo3/solr` | 14.0.0-RC1 | Site search: indexing pipeline, query API | +| Apache Solr | 10.0.0 (dedicated VPS) | Search server, reached via an HTTPS reverse proxy | | `evomedien/vitec` | ^1.0 | This project’s custom extension | > **NOTE** `friendsoftypo3/headless` is pinned to a **release candidate** (`^5.0@rc`). -> This is an upgrade‑sensitivity point; see 10.4. +> This is an upgrade‑sensitivity point; see 10.4. The same applies to +> `apache-solr-for-typo3/solr`, pinned to `14.0.0-RC1` (the only line for TYPO3 14). ### 5.2 Site configuration The headless mode is activated in `config/sites/vitec/config.yaml`: @@ -175,7 +180,9 @@ dependencies: The Site **Sets** listed under `dependencies` load, in order, the headless TypoScript base, the mixed‑mode overrides, the Content Blocks JSON integration and the News integration. The VITEC Set (`EXT:vitec/Configuration/Sets/Vitecset`) layers the custom -definitions on top. The headless page response carries +definitions on top; it declares `apache-solr-for-typo3/solr` as a set dependency so +the solr defaults load **before** the VITEC overrides - sets load in dependency +order, and a set loaded later silently wins. The headless page response carries `Content-Type: application/json; charset=utf-8`. Slug routing is configured with route enhancers for products (`tx_vitec_domain_model_product.slug`) and news detail (`path_segment`). @@ -189,7 +196,7 @@ that is, **before page resolution**: |---|---| | `vitec/form-submission` | Answers `POST /api/vitec/form/` (Clause 7.8.2). Every other request passes through untouched. | | `vitec/success-story-path-rewrite` | Rewrites `/success-stories/` internally to `/success-stories/story/` when `` matches a visible Success Story. The page router would otherwise always resolve the public SEO URL to the list page (longest page‑slug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a non‑matching slug leaves the request untouched; any exception leaves the request untouched. | -| `vitec/download-file` | Answers `GET /download/file/`: resolves the download record's file (`DownloadFileResolver`: FAL → Collateral naming convention → `filepath`) and streams it with `Content-Disposition: attachment` (Clause 9.12). Unknown uid, hidden record or missing file fall through to normal page resolution. | +| `vitec/download-file` | Answers `GET /download/` (canonical; used by search results) and `GET /download/file/` (legacy; the download record links build this form): resolves the download record’s file (`DownloadFileResolver`: FAL → Collateral naming convention → `filepath`) and streams it with `Content-Disposition: attachment` (Clause 9.12). Records flagged `private_download` are refused. Unknown uid/slug, hidden or private record or missing file fall through to normal page resolution. | ### 5.4 Architectural principles (rationale) - **P1 — One envelope.** Every content element, regardless of source, is exposed with @@ -897,6 +904,84 @@ rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note). --- +### 7.16 Search payload (`solr_pi_results`) + +The site search runs on Apache Solr through EXT:solr. The EXT:solr results plugin +`solr_pi_results` sits on the dedicated search page (`/search`) and is rendered +headless by `SearchJsonRenderer` under the payload key `search`. The renderer runs +EXT:solr’s own query pipeline (`SearchRequestBuilder` → `SearchResultSetService`) - +only the rendering differs from the stock Fluid plugin. + +**Request.** GET parameters on the search page: + +| Parameter | Meaning | +|---|---| +| `q` | Search terms. Absent or empty: the response keeps its full shape with `numFound: 0`, so the front end never needs a second code path. | +| `page` | 1-based result page, optional. | +| `filter` | Restrict results to one type from the `type` vocabulary below (e.g. `filter=product`). Unknown values are ignored. Named `filter` because `type` is TYPO3’s reserved page-type parameter. | + +The EXT:solr namespace (`tx_solr[q]`, `tx_solr[page]`) is accepted as a fallback. +All three parameters are excluded from cHash validation; the search page is never +served from the page cache (the renderer disables caching per request - cached +variants would collide because excluded parameters do not enter the cache +identifier). + +**Response** under `content.search`: + +```json +{ + "query": "encoder", + "page": 1, + "resultsPerPage": 10, + "numFound": 202, + "totalPages": 21, + "filter": null, + "facets": { + "type": [ + { "value": "news", "count": 137, "active": false }, + { "value": "product", "count": 13, "active": false } + ] + }, + "results": [ + { "title": "MGW Diamond-H", "url": "/product/mgw-diamond-h-hdmi-encoder", + "type": "product", "teaser": "… 4K HDMI Encoder …" } + ], + "suggestions": [] +} +``` + +- `type` **shall** be one of `page | product | market | story | news | download` + (mapping table in the renderer; unknown index types pass through verbatim). +- `teaser` carries the highlighted fragment (``, fragments joined + with " ... ") when highlighting applies, otherwise a plain 250-character excerpt + of the indexed content. It is the only payload field containing markup. +- `url` follows the shared link contract: root-relative, resolved server-side. + `download` results point at the forced-download endpoint `/download/` + (5.3) - front ends should present them as file downloads. +- `facets.type` carries the per-type counts for filter tabs, ordered by count. + While a `filter` is active the counts remain those of the unfiltered query, so + tabs do not collapse - except when the filtered result is empty, where only + the active option (count 0) is returned; front ends should then offer + "remove filter" rather than rely on the other counts. +- `filter` echoes the active type filter, `null` when none. +- `suggestions` lists spellcheck alternatives ("did you mean"), `[]` if none. + +**Indexed corpus** (`plugin.tx_solr.index.queue`): pages, plus records with a +resolvable public URL - products, markets (only those with `detail_page`), +use cases, news (`type = 0`; "page as news" records are excluded because their +target pages are already indexed) and downloads (`private_download = 0 AND +hideonwebsite = 0`). Solutions are not indexed until they carry slugs or detail +pages. Ranking boosts products (^10) and stories (^2). Every executed search is +logged to `tx_solr_statistics` with the last two IP octets masked. + +**Operations.** Indexing runs automatically: record and page saves enter the +index queue, a scheduler task (Index Queue Worker, every 5 minutes) pushes them +to Solr; the CLI command `vitec:solr-index` (with `--initialize` and `--debug`) +covers manual runs - EXT:solr 14 ships no console commands of its own. Page +content is extracted from the page’s `tt_content` rows via +`index.queue.pages.fields.content` because the headless JSON output carries no +`TYPO3SEARCH` markers. + ## 8 Content‑type catalogue | CType | TS pattern | Renderer / Processor | Payload key | Kind | @@ -920,6 +1005,7 @@ rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note). | `vitec_demoform` | `< tt_content.vitec_contactform` | FormsJsonRenderer | `form` | form | | `vitec_helpdeskform` | `< tt_content.vitec_contactform` | FormsJsonRenderer | `form` | form | | `news_pi1` (+8 variants) | `< lib.…WithHeader`; variants `< tt_content.news_pi1` | NewsJsonRenderer | `news` | list/detail | +| `solr_pi_results` | `< lib.…WithHeader` | SearchJsonRenderer | `search` | search (7.16) | | `vitec_cols_50_50` | `= JSON` | ContainerChildrenProcessor | `items` | container | | `vitec_cols_33_66 / 66_33 / 33_33_33 / 25_25_25_25` | `< vitec_cols_50_50` | ContainerChildrenProcessor | `items` | container | | `vitec_container` | `< vitec_cols_50_50` | ContainerChildrenProcessor | `items` | container (1 col) | @@ -1070,7 +1156,7 @@ and resolved by `config.recordLinks` in `setup.typoscript`: | Identifier | Resolves to | Notes | |---|---|---| | `product` | `/product/` | same convention as the `link` member (7.15); assumes the product detail page (uid 10) answers `/product` | -| `download` | `/download/file/` | forced‑download endpoint (5.3), file lookup via `DownloadFileResolver` | +| `download` | `/download/file/` | forced‑download endpoint (5.3); the canonical public form is `/download/` - both routes share `DownloadFileResolver`, which refuses `private_download` records | --- @@ -1264,4 +1350,4 @@ remediation. - Header convention — the uniform header section across CEs, plugins and containers. - `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point. -*End of document EVO‑VITEC‑HL‑001 v1.10.* +*End of document EVO‑VITEC‑HL‑001 v1.12.* diff --git a/packages/vitec/README.md b/packages/vitec/README.md index f99fff1..2aeb11d 100755 --- a/packages/vitec/README.md +++ b/packages/vitec/README.md @@ -59,6 +59,9 @@ this extension turns every content element into clean **JSON** for a React front React app renders *and* the server‑side validation of the submission. - 🔎 **SEO built in** — a schema.org `@graph` (Organization, Product, FAQ, Events, News …) is emitted per page. +- 🔍️ **Full-text search** - Apache Solr indexes pages, products, stories, news and + downloads; the search page answers as JSON (query, paging, highlighted teasers, + typed results) - Clause 7.16 of the architecture spec. - 🛡️ **Fail‑soft** — a failing element yields empty output, never a broken page. ## Architecture at a glance @@ -227,8 +230,10 @@ the forced-download endpoint. | `vitec:market-dummy-image` | Assign the shared placeholder image to markets without an image | | `vitec:migrate-newspages` | Rewire impexp-imported old-site news pages (FLUX `colPos` nesting, `internalurl`) via `tx_impexp_origuid` | | `vitec:import-news` | Import old-site news records from `migrations/news_export.json` | +| `vitec:solr-index` | Work the Solr index queue from the CLI (`--initialize`, `--debug` single-stepping) - EXT:solr 14 ships no console commands of its own | -All commands support `--dry-run` and are safe to re-run. +All import/migration commands support `--dry-run` and are safe to re-run; +`vitec:solr-index` has no dry-run (indexing is idempotent anyway). ## Requirements diff --git a/packages/vitec/ext_localconf.php b/packages/vitec/ext_localconf.php index 65dcfd9..a4014d9 100755 --- a/packages/vitec/ext_localconf.php +++ b/packages/vitec/ext_localconf.php @@ -257,3 +257,4 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1750000000] = [ // sure search responses are rendered fresh instead of sticking in the page cache. $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'q'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page'; +$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';