*/ protected const DICTIONARIES = ['es' => 'spanish', 'en' => 'english']; /** * Type => what is searched in it: the title field, the fields with * weight B, and whether those fields are translatable. * * @var array, title: string, translatedTitle: bool, text: array}> */ protected const SOURCES = [ 'page' => ['model' => Page::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['excerpt', 'body']], 'project' => ['model' => Project::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']], 'service' => ['model' => Service::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']], 'call' => ['model' => Call::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['description']], 'member' => ['model' => Member::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['headline', 'bio']], 'technology' => ['model' => Technology::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['description']], ]; public const TYPES = ['page', 'project', 'service', 'call', 'member', 'technology']; /** * @param array $types * @return Collection> */ public function search(string $query, string $locale, array $types = self::TYPES, int $limit = 20): Collection { $query = trim($query); if (mb_strlen($query) < 2) { return collect(); } $postgres = DB::connection()->getDriverName() === 'pgsql'; return collect(self::SOURCES) ->only($types) ->flatMap(fn (array $source, string $type) => $this->searchIn($type, $source, $query, $locale, $postgres, $limit)) ->sortByDesc('score') ->take($limit) ->values(); } /** * @param array{model: class-string, title: string, translatedTitle: bool, text: array} $source * @return Collection> */ protected function searchIn(string $type, array $source, string $query, string $locale, bool $postgres, int $limit): Collection { $builder = $this->visible($source['model']::query(), $source['model']); $title = $source['translatedTitle'] ? $this->json($source['title'], $locale, $postgres) : $source['title']; $text = collect($source['text'])->map(fn (string $field) => $this->json($field, $locale, $postgres)); if ($postgres) { $dictionary = self::DICTIONARIES[$locale] ?? 'simple'; // Tags out of rich text, so `` is not a word anyone finds. $body = $text->map(fn (string $sql) => "regexp_replace(coalesce({$sql}, ''), '<[^>]+>', ' ', 'g')")->implode(" || ' ' || "); $document = "setweight(to_tsvector('{$dictionary}', coalesce({$title}, '')), 'A') || setweight(to_tsvector('{$dictionary}', {$body}), 'B')"; $tsquery = "websearch_to_tsquery('{$dictionary}', ?)"; $builder ->select('*') ->selectRaw("ts_rank({$document}, {$tsquery}) as search_score", [$query]) ->whereRaw("{$document} @@ {$tsquery}", [$query]) ->orderByDesc('search_score'); } else { $words = collect(preg_split('/\s+/', $query))->filter()->map(fn ($w) => '%'.mb_strtolower($w).'%'); foreach ($words as $word) { $builder->where(function (Builder $q) use ($title, $text, $word): void { $q->whereRaw("lower({$title}) like ?", [$word]); foreach ($text as $sql) { $q->orWhereRaw("lower({$sql}) like ?", [$word]); } }); } } return $builder->limit($limit)->get()->map(fn (Model $record) => $this->result($type, $source, $record, $query, $locale)); } /** * @param array{model: class-string, title: string, translatedTitle: bool, text: array} $source * @return array */ protected function result(string $type, array $source, Model $record, string $query, string $locale): array { $title = $source['translatedTitle'] ? (string) $record->getTranslation($source['title'], $locale, false) : (string) $record->getAttribute($source['title']); $text = collect($source['text']) ->map(fn (string $field) => (string) $record->getTranslation($field, $locale, false)) ->implode(' '); // Crude where Postgres did not rank: a title match counts more. $score = $record->getAttribute('search_score') ?? (Str::contains(mb_strtolower($title), mb_strtolower($query)) ? 2.0 : 1.0); return [ 'type' => $type, 'id' => $record->getKey(), 'title' => $title, 'excerpt' => $this->excerpt($text, $query), 'path' => $this->path($type, $record, $locale), 'score' => round((float) $score, 4), ]; } /** * Published, open or public, whichever each kind calls it. * * @param class-string $model */ protected function visible(Builder $query, string $model): Builder { return match ($model) { Call::class => $query->open(), Member::class => $query->public(), // A technology is shown on the site once something uses it. Technology::class => $query->whereHas('projects', fn (Builder $q) => $q->published()), default => $query->published(), }; } /** * The text in one language of a translatable JSON column. */ protected function json(string $field, string $locale, bool $postgres): string { return $postgres ? "({$field}->>'{$locale}')" : "json_extract({$field}, '$.{$locale}')"; } /** * Some words either side of the first match, out of plain text. */ protected function excerpt(string $html, string $query): string { $text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($html)))); $first = collect(preg_split('/\s+/', $query))->filter()->first() ?? $query; $at = mb_stripos($text, (string) $first); if ($at === false || $at < 60) { return Str::limit($text, 180); } return '…'.Str::limit(mb_substr($text, $at - 60), 180); } protected function path(string $type, Model $record, string $locale): ?string { return match ($type) { 'member' => SiteUrls::path('member', $locale, (string) $record->slug), // The site gives technologies pages, but this side has no address // for them: the slug is enough to build one. 'technology' => null, default => SiteUrls::alternates($record, $type)[$locale] ?? null, }; } }