Add Solr search: EXT:solr 14 integration and JSON search endpoint

Apache Solr 10 runs on a dedicated VPS behind a Caddy HTTPS proxy
(vitecsolr.evomedien.de) because the managed webserver only allows
outgoing standard ports. Credentials stay out of git: the site config
carries %env()% placeholders resolved via putenv() in the git-ignored
config/system/additional.php.

- composer: apache-solr-for-typo3/solr 14.0.0-RC1 (the only line
  compatible with TYPO3 14; final 14.0.0 will arrive via composer update)
- config.yaml: read connection https/443, core_en per language,
  env placeholder credentials; .gitignore covers additional.php
- setup.typoscript: config.index_enable = 1 (page indexing silently
  refuses without it), solr_pi_results JSON renderer registration,
  results highlighting, and content field extraction from tt_content
  rows - the headless JSON output has no TYPO3SEARCH markers, so the
  default page content extraction indexed an empty content field
- SearchJsonRenderer (renderer #27): JSON output for the search plugin
  on /search. GET q/page in; { query, page, resultsPerPage, numFound,
  totalPages, results[], suggestions } out. Disables the page cache per
  request: config.no_cache is gone in TYPO3 v14, and q/page are
  excluded from cHash, so cached variants would collide
- ext_localconf.php: q and page added to cacheHash excludedParameters
- SolrIndexCommand (vitec:solr-index): works the index queue from the
  CLI with connection diagnostics and a --debug single-step mode -
  EXT:solr 14 ships no console commands and the backend button indexes
  one item per click
This commit is contained in:
2026-08-21 14:43:44 +02:00
parent 7bd1161abf
commit 710f95794c
11 changed files with 779 additions and 6 deletions

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use ApacheSolrForTypo3\Solr\ConnectionManager;
use ApacheSolrForTypo3\Solr\Domain\Search\ResultSet\SearchResultSetService;
use ApacheSolrForTypo3\Solr\Domain\Search\SearchRequestBuilder;
use ApacheSolrForTypo3\Solr\Search;
use ApacheSolrForTypo3\Solr\System\Configuration\ConfigurationManager;
use ApacheSolrForTypo3\Solr\System\Service\ConfigurationService;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* UserFunc: render EXT:solr search results as JSON (headless).
*
* Output under content.search:
* {
* "query": the search terms as used,
* "page": 1-based current page,
* "resultsPerPage": page size (plugin.tx_solr.search.results.resultsPerPage
* or the FlexForm override on the plugin),
* "numFound": total hits,
* "totalPages": ceil(numFound / resultsPerPage),
* "results": [ { "title", "url", "type", "teaser" } ],
* "suggestions": spellcheck alternatives ("did you mean"), [] if none
* }
*
* Request parameters (GET, on the page carrying the plugin):
* 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
* The EXT:solr namespace (tx_solr[q], tx_solr[page]) is accepted as a
* fallback so classic solr URLs keep working.
*
* The search itself is EXT:solr's own pipeline (SearchRequestBuilder ->
* SearchResultSetService), i.e. identical to what the Fluid plugin runs -
* only the rendering is JSON instead of HTML. `teaser` carries the
* highlighted fragment when highlighting is enabled, otherwise a plain
* 250-char excerpt of the indexed content.
*
* `render()` = top-level plugin / page discovery. `renderForRecord()` = one
* specific tt_content row (reused by ContainerChildrenProcessor for nested
* plugins). Exception-safe.
*/
class SearchJsonRenderer
{
private ?ContentObjectRenderer $cObj = null;
/** Same duck-typed setter contract as the other renderers (see MarketListJsonRenderer). */
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
private const CTYPE = 'solr_pi_results';
private const TEASER_FALLBACK_LENGTH = 250;
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$row = is_array($this->cObj?->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === self::CTYPE) {
return $this->renderForRecord($row);
}
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter(self::CTYPE, ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($ces)) {
return '';
}
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request === null) {
return '';
}
// Search responses depend on the q/page GET parameters, which are
// 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');
$params = $request->getQueryParams();
$solrNamespace = (array)($params['tx_solr'] ?? []);
$query = trim((string)($params['q'] ?? $solrNamespace['q'] ?? ''));
$page = max(1, (int)($params['page'] ?? $solrNamespace['page'] ?? 1));
if ($query === '') {
return (string)json_encode($this->emptyResult());
}
$languageId = (int)($request->getAttribute('language')?->getLanguageId() ?? 0);
$pageId = (int)($request->getAttribute('frontend.page.information')?->getId() ?? 0);
$typoScriptConfiguration = GeneralUtility::makeInstance(ConfigurationManager::class)
->getTypoScriptFromRequest($request);
if (!empty($contentElement['pi_flexform'])) {
GeneralUtility::makeInstance(ConfigurationService::class)->overrideConfigurationWithFlexFormSettings(
(string)$contentElement['pi_flexform'],
$typoScriptConfiguration,
);
}
$connection = GeneralUtility::makeInstance(ConnectionManager::class)
->getConnectionByTypo3Site($request->getAttribute('site'), $languageId);
$search = GeneralUtility::makeInstance(Search::class, $connection);
$searchService = GeneralUtility::makeInstance(
SearchResultSetService::class,
$typoScriptConfiguration,
$search,
);
$searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration)
->buildForSearch(['q' => $query, 'page' => $page], $pageId, $languageId);
$resultSet = $searchService->search($searchRequest);
$highlighted = null;
try {
$highlighted = $resultSet->getUsedSearch()?->getHighlightedContent();
} catch (\Throwable) {
// no highlighting available - the excerpt fallback below covers it
}
$results = [];
foreach ($resultSet->getSearchResults() as $document) {
$id = (string)$document->getId();
if ($highlighted !== null && !empty($highlighted->{$id}->content[0])) {
$teaser = implode(' ... ', $highlighted->{$id}->content);
} else {
$teaser = mb_substr(trim((string)$document->getContent()), 0, self::TEASER_FALLBACK_LENGTH);
}
$results[] = [
'title' => (string)$document->getTitle(),
'url' => (string)$document->getUrl(),
'type' => (string)$document->getType(),
'teaser' => $teaser,
];
}
$suggestions = [];
try {
foreach ($resultSet->getSpellCheckingSuggestions() as $suggestion) {
$word = method_exists($suggestion, 'getSuggestion')
? (string)$suggestion->getSuggestion()
: '';
if ($word !== '') {
$suggestions[] = $word;
}
}
} catch (\Throwable) {
// spellchecking disabled or unavailable - not essential
}
$numFound = $resultSet->getAllResultCount();
$resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results);
return (string)json_encode([
'query' => $query,
'page' => $page,
'resultsPerPage' => $resultsPerPage,
'numFound' => $numFound,
'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0,
'results' => $results,
'suggestions' => array_values(array_unique($suggestions)),
]);
} catch (\Throwable $e) {
return '';
}
}
/**
* @return array<string,mixed>
*/
private function emptyResult(): array
{
return [
'query' => '',
'page' => 1,
'resultsPerPage' => 0,
'numFound' => 0,
'totalPages' => 0,
'results' => [],
'suggestions' => [],
];
}
}