Add type filter and facet counts to the search endpoint

The search JSON gains tab support: the new `filter` GET parameter
restricts results to one document type (product, news, download, story,
page, market); the response carries `filter` (active value or null) and
`facets.type` with per-type counts and active flags. Counts stay
complete while a filter is active (keepAllFacetsOnSelection), so tabs
never collapse - except on an empty filtered result, documented in spec
v1.12 clause 7.16.

The parameter is named `filter` because `type` is TYPO3's reserved
page-type parameter and crashes page resolution; like q and page it is
excluded from cHash validation. Facets come from EXT:solr's native
faceting, serialized generically by SearchJsonRenderer - a future
category facet only needs TypoScript.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:51:26 +02:00
parent cf7a04ea15
commit 083f0937e2
5 changed files with 162 additions and 10 deletions

View File

@@ -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' => [],
];

View File

@@ -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
}
}
}

View File

@@ -3,9 +3,9 @@
| | |
|---|---|
| **Document identifier** | EVOVITECHL001 |
| **Version** | 1.9 |
| **Version** | 1.12 |
| **Status** | Released |
| **Date** | 20260815 |
| **Date** | 20260824 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch |
@@ -24,6 +24,8 @@
| 1.8 | 20260813 | **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 toplevel blocks (B13), and `appearance.layout` is represented differently on the two envelope paths (B14). `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). B11 and B12 recorded as resolved. |
| 1.9 | 20260815 | **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 | 20260818 | **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`, pagelevel shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved serverside per the new Clause 9.12; new middleware `vitec/download-file` streams `/download/file/<uid>` as a forced download (5.3), file lookup consolidated into `DownloadFileResolver` — first step towards the B4 target (10.2). Backendonly: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. |
| 1.11 | 20260821 | **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/<slug>` (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 sets TypoScript requires loading after it (5.2). Editorial: the header table had been stuck at v1.9 since v1.10. |
| 1.12 | 20260824 | **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 TYPO3s 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 projects custom extension |
> **NOTE** `friendsoftypo3/headless` is pinned to a **release candidate** (`^5.0@rc`).
> This is an upgradesensitivity point; see 10.4.
> This is an upgradesensitivity 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 mixedmode 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/<formKey>` (Clause 7.8.2). Every other request passes through untouched. |
| `vitec/success-story-path-rewrite` | Rewrites `/success-stories/<slug>` internally to `/success-stories/story/<slug>` when `<slug>` matches a visible Success Story. The page router would otherwise always resolve the public SEO URL to the list page (longest pageslug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a nonmatching slug leaves the request untouched; any exception leaves the request untouched. |
| `vitec/download-file` | Answers `GET /download/file/<uid>`: 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/<slug>` (canonical; used by search results) and `GET /download/file/<uid>` (legacy; the download record links build this form): resolves the download records 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:solrs 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 TYPO3s 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 <mark>Encoder</mark> …" }
],
"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 (`<mark>…</mark>`, 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/<slug>`
(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 pages `tt_content` rows via
`index.queue.pages.fields.content` because the headless JSON output carries no
`TYPO3SEARCH` markers.
## 8 Contenttype 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/<slug>` | same convention as the `link` member (7.15); assumes the product detail page (uid 10) answers `/product` |
| `download` | `/download/file/<uid>` | forceddownload endpoint (5.3), file lookup via `DownloadFileResolver` |
| `download` | `/download/file/<uid>` | forceddownload endpoint (5.3); the canonical public form is `/download/<slug>` - 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 EVOVITECHL001 v1.10.*
*End of document EVOVITECHL001 v1.12.*

View File

@@ -59,6 +59,9 @@ this extension turns every content element into clean **JSON** for a React front
React app renders *and* the serverside 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.
- 🛡️ **Failsoft** — 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

View File

@@ -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';