This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 8ee2365717d Find providers in the registry by the external services
they reach (#71478)
8ee2365717d is described below
commit 8ee2365717daf77d452586b4a61d4973a7989186
Author: Wei Lee <[email protected]>
AuthorDate: Wed Sep 16 21:18:34 2026 +0800
Find providers in the registry by the external services they reach (#71478)
* Find providers in the registry by the external services they reach
The names people type into registry search are often the service they want
to
talk to, not the Airflow distribution that talks to it -- "openai" or
"ollama"
rather than "common-ai". Those names were declared in provider.yaml and
rendered
in a table on the provider page, but neither search path read them, so the
queries most likely to lead someone to Common AI returned nothing.
Both search paths now read the same deduped list off the same field, so a
query
that works in the global search box also works in the /providers/ filter
box.
* Badge the external service that actually matched the search query
The badge is meant to name the service that surfaced a provider, but it took
the first entry the query appeared anywhere inside, so "ai" credited OpenAI
and "llm" credited vLLM. It also compared raw text while the two other
search
paths collapse runs of "-_\s" first, so a hyphenated query like "mistral-ai"
matched no service at all and got no badge.
Matching a word start after the same collapse gives all three paths the same
notion of a match. That normalize() now has a third hand-written copy, so
the
sync note in each of the three files names the other two.
* Let the ai-ml explore category match a provider's external services
The filter box on /providers/ already matched the services a provider's
connections reach, but the category dropdown beside it did not, so a
provider
that gets to OpenAI only through external-services could be found by typing
and not by filtering.
Feeding those names to every category would misfile providers instead. A
bare
service name like "Google" collides with the cloud category, whose keywords
are provider distribution ids -- an identity claim, not something a provider
reaches. Only ai-ml keys on integration names, the same axis external
services
describe, so it is the one category that opts in and the default stays off.
* Stop badging unrelated services on search hits that matched by name
The service badge next to a search result exists to show which external
service a provider was found through, when that is not obvious from the
provider's own name. It was derived purely from the query string, with no
check for whether the query already matched the provider's own identity:
searching "ai" surfaced Common AI on its own name and then badged it
"Mistral AI", and searching "a" badged it "Anthropic" — services the query
had nothing to do with.
Skipping the service lookup whenever the provider's own name or id already
explains the hit removes those misleading badges without losing any of the
genuine ones.
* Replace the ai-ml opt-in comment's false rule with a measured fact
The comment explaining why only ai-ml opts into external-service matching
is the only written rationale for that flag anywhere, and it claimed every
other category's keywords are this-distribution-only identity claims. That
is false in both directions: dingtalk, dataflow, couchbase and twilio are
not directory ids either, while 7 of ai-ml's own 13 keywords are.
What replaces it is falsifiable: turning the flag on for every category,
ai-ml included, changes exactly one other — cloud would gain Common AI via
`google` matching "Google" and "Google Vertex AI". Common AI is not a cloud
platform, so that is the false positive the opt-in stays scoped to avoid.
* Document the external-service opt-in in the category matcher's README
The registry README is the only place the explore-category matcher is
explained, and it still described keywords as matching a provider's id and
declared integration names only. Since a category can now opt in to also
matching the provider's declared external services, anyone reading the
README would have had no way to know the option exists, what it defaults
to, or where it is declared.
* Keep a provider out of the search text for services another provider owns
Indexing every external service name a provider reaches gives short records
an advantage that BM25's length normalisation turns into a ranking win over
the provider that actually implements the service: Common AI's record
reaches
for "AWS Bedrock" and "Google Vertex AI" in a fraction of the words Amazon
and Google spend describing their own.
Compound service names whose words all belong to another provider's own
integrations are now left out of the indexed text, while the badge and the
/providers/ filter still see the full list. Bare vendor names stay indexed:
dropping them on an exact-name match would have cost the discovery this PR
adds, since a query like "ollama" or "groq" has no other provider to find.
---
registry/.eleventy.js | 10 +++
registry/README.md | 19 +++--
registry/scripts/build-pagefind-index.mjs | 16 +++-
registry/src/_data/exploreCategories.js | 7 ++
registry/src/_data/exploreCategoryProviders.js | 9 +-
registry/src/_data/providerCategoryMap.js | 9 +-
registry/src/_data/providerExternalServices.js | 113 +++++++++++++++++++++++++
registry/src/_data/providerKeywordMatch.js | 38 ++++++---
registry/src/css/main.css | 7 ++
registry/src/js/provider-filters.js | 12 +--
registry/src/js/search.js | 36 ++++++++
registry/src/providers.njk | 3 +-
12 files changed, 242 insertions(+), 37 deletions(-)
diff --git a/registry/.eleventy.js b/registry/.eleventy.js
index 6a61ca7d792..83496beb4f5 100644
--- a/registry/.eleventy.js
+++ b/registry/.eleventy.js
@@ -17,6 +17,8 @@
* under the License.
*/
+const { collectExternalServices } =
require("./src/_data/providerExternalServices");
+
module.exports = function(eleventyConfig) {
// Copy static assets
eleventyConfig.addPassthroughCopy("src/assets");
@@ -121,6 +123,14 @@ module.exports = function(eleventyConfig) {
return typeId.charAt(0).toUpperCase();
});
+ // Flattens a provider's per-connection external services into one deduped
+ // list, so the /providers/ filter box can match them the way the pagefind
+ // index already does.
+ eleventyConfig.addFilter("externalServices", (provider) => {
+ if (!provider || typeof provider !== "object") return [];
+ return collectExternalServices(provider);
+ });
+
eleventyConfig.addShortcode("year", () => `${new Date().getFullYear()}`);
return {
diff --git a/registry/README.md b/registry/README.md
index 803156c1c4d..9502927a86e 100644
--- a/registry/README.md
+++ b/registry/README.md
@@ -257,15 +257,22 @@ The Explore page and provider filtering use categories
defined in
- `id` — URL-safe identifier (e.g., `cloud`, `databases`, `ai-ml`)
- `name` — Display name
- `keywords` — List of substrings matched against `provider.id` and the
provider's
- declared integration names (`provider.categories[].name`)
+ declared integration names (`provider.categories[].name`); categories that
set
+ `includeExternalServices: true` also match against the provider's declared
+ external services
+- `includeExternalServices` — optional boolean, default `false`; when `true`,
`keywords`
+ also match the provider's declared external services in addition to
`provider.id`
+ and integration names (currently only `ai-ml` sets this)
- `icon`, `color`, `description` — Visual properties
Providers are assigned to categories at build time by checking if any keyword
in a
-category is a substring of the provider's ID or of one of its declared
integration
-names, compared case-insensitively after collapsing runs of `-`, `_` and
whitespace to
-a single space (so `pydantic-ai` matches the integration named "Pydantic AI").
The
-matcher lives in `src/_data/providerKeywordMatch.js`. A provider can belong to
multiple
-categories.
+category is a substring of the provider's ID, one of its declared integration
names,
+or — for categories that set `includeExternalServices: true` — one of its
declared
+external services, compared case-insensitively after collapsing runs of `-`,
`_` and
+whitespace to a single space (so `pydantic-ai` matches the integration named
+"Pydantic AI"). The matcher lives in `src/_data/providerKeywordMatch.js`; which
+categories opt in to matching external services is declared per-category in
+`src/_data/exploreCategories.js`. A provider can belong to multiple categories.
### Homepage "New Providers" Section
diff --git a/registry/scripts/build-pagefind-index.mjs
b/registry/scripts/build-pagefind-index.mjs
index 027226ef70b..4aa4b4922ca 100644
--- a/registry/scripts/build-pagefind-index.mjs
+++ b/registry/scripts/build-pagefind-index.mjs
@@ -20,6 +20,8 @@
import fs from 'fs';
import * as pagefind from "pagefind";
+import { collectExternalServices, filterServicesForSearchText } from
'../src/_data/providerExternalServices.js';
+
async function buildPagefindIndex() {
console.log('Building PageFind index with custom records...');
@@ -44,20 +46,30 @@ async function buildPagefindIndex() {
.map((category) => category.name)
.filter(Boolean)
.join(' ');
+ const externalServicesList = collectExternalServices(provider);
+ // Colliding names (e.g. "AWS Bedrock") are dropped here only -- pagefind's
+ // length-normalised BM25 would otherwise let a mention in Common AI's much
+ // shorter record outrank the provider that actually implements it.
+ const searchTextServices =
filterServicesForSearchText(externalServicesList, provider,
providers.providers);
+ const externalServices = searchTextServices.join(' ');
await index.addCustomRecord({
url: `/providers/${provider.id}/${provider.version}/`,
// The id is indexed on its own, not as part of the distribution name:
// the `apache-airflow-providers-` prefix is shared by every provider and
// would make 'apache' or 'airflow' match all of them.
- content: `${provider.name} ${provider.id} ${provider.description}
${integrations}`,
+ content: `${provider.name} ${provider.id} ${provider.description}
${integrations} ${externalServices}`,
language: 'en',
meta: {
type: 'provider',
name: provider.name,
description: provider.description,
providerId: provider.id,
- providerName: provider.name
+ providerName: provider.name,
+ // Comma-joined so search.js can tell a query apart from a plain
+ // description/category match and badge the specific service it hit
+ // (e.g. "anthropic" surfaces the provider *and* which service
matched).
+ externalServices: externalServicesList.join(',')
},
filters: {
type: ['provider']
diff --git a/registry/src/_data/exploreCategories.js
b/registry/src/_data/exploreCategories.js
index 04a3de1e042..e5b8d267a63 100644
--- a/registry/src/_data/exploreCategories.js
+++ b/registry/src/_data/exploreCategories.js
@@ -57,6 +57,13 @@ module.exports = [
color: 'amber',
keywords: ['openai', 'cohere', 'anthropic', 'huggingface', 'mlflow',
'pinecone', 'qdrant', 'weaviate', 'pgvector', 'langchain', 'llamaindex', 'mcp',
'pydantic ai'],
description: 'OpenAI, vector DBs, and ML platforms',
+ // Opt-in only: ai-ml's keywords are capability claims ("this provider
+ // integrates with X"), same axis as `external-services`
+ // (provider.yaml.schema.json). Forcing this on for every category, ai-ml
+ // included, would change exactly one other: `cloud` would gain Common AI
+ // via `google` matching "Google"/"Google Vertex AI". Common AI isn't a
+ // cloud platform, so this stays scoped to ai-ml.
+ includeExternalServices: true,
},
{
id: 'data-processing',
diff --git a/registry/src/_data/exploreCategoryProviders.js
b/registry/src/_data/exploreCategoryProviders.js
index 0b0365733a5..4c6c5e02eb1 100644
--- a/registry/src/_data/exploreCategoryProviders.js
+++ b/registry/src/_data/exploreCategoryProviders.js
@@ -19,18 +19,15 @@
const providersData = require("./providers.json");
const exploreCategories = require("./exploreCategories");
-const { providerMatchesKeyword } = require("./providerKeywordMatch");
+const { categoryMatchesProvider } = require("./providerKeywordMatch");
module.exports = function () {
const map = {};
for (const category of exploreCategories) {
const matched = [];
for (const provider of providersData.providers) {
- for (const keyword of category.keywords) {
- if (providerMatchesKeyword(provider, keyword)) {
- matched.push(provider);
- break;
- }
+ if (categoryMatchesProvider(category, provider)) {
+ matched.push(provider);
}
}
// explore.njk shows only the first six as badges, so rank before slicing
diff --git a/registry/src/_data/providerCategoryMap.js
b/registry/src/_data/providerCategoryMap.js
index 8daab272058..37f6df2bff4 100644
--- a/registry/src/_data/providerCategoryMap.js
+++ b/registry/src/_data/providerCategoryMap.js
@@ -19,18 +19,15 @@
const providersData = require("./providers.json");
const exploreCategories = require("./exploreCategories");
-const { providerMatchesKeyword } = require("./providerKeywordMatch");
+const { categoryMatchesProvider } = require("./providerKeywordMatch");
module.exports = function () {
const map = {};
for (const provider of providersData.providers) {
const cats = [];
for (const category of exploreCategories) {
- for (const keyword of category.keywords) {
- if (providerMatchesKeyword(provider, keyword)) {
- cats.push(category.id);
- break;
- }
+ if (categoryMatchesProvider(category, provider)) {
+ cats.push(category.id);
}
}
if (cats.length > 0) {
diff --git a/registry/src/_data/providerExternalServices.js
b/registry/src/_data/providerExternalServices.js
new file mode 100644
index 00000000000..1252e9e77a7
--- /dev/null
+++ b/registry/src/_data/providerExternalServices.js
@@ -0,0 +1,113 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Shared by the pagefind index builder, the `externalServices` Eleventy
+// filter (used for the /providers/ filter box), and providerKeywordMatch.js
+// (used by the `ai-ml` explore category's opt-in keyword matching), so all
+// three search paths read the same field off the same shape and can't drift
+// apart.
+
+// The services a provider's connection types reach -- for Common AI these are
+// the LLM providers behind each `pydanticai` / `langchain` connection, which
are
+// the names people actually search for ("openai", "ollama") even though no
+// provider is called that.
+function collectExternalServices(provider) {
+ const seen = new Set();
+ const services = [];
+ for (const connection of provider.connection_types || []) {
+ for (const service of connection.external_services || []) {
+ const key = service.toLowerCase();
+ if (!seen.has(key)) {
+ seen.add(key);
+ services.push(service);
+ }
+ }
+ }
+ return services;
+}
+
+// Generic tech/AI words that don't distinguish one vendor's name from another
+// ("Mistral AI" vs "Mistral"); stripped before collision-checking a service
+// name against another provider's own category name.
+const SEARCH_TEXT_STOPWORDS = new Set(['ai', 'ml', 'llm', 'gpt', 'api',
'sdk']);
+const SEARCH_TEXT_MIN_TOKEN_LENGTH = 3;
+
+// Small, curated cloud-vendor abbreviations, not general synonym resolution --
+// only added when load-bearing for an actual collision (see
providerExternalServices
+// design notes in the collision-detection PR).
+const SEARCH_TEXT_VENDOR_ALIASES = { aws: 'amazon', gcp: 'google' };
+
+function tokenizeForCollisionCheck(name) {
+ const tokens = new Set();
+ for (let token of name.toLowerCase().split(/\s+/)) {
+ token = SEARCH_TEXT_VENDOR_ALIASES[token] || token;
+ if (!SEARCH_TEXT_STOPWORDS.has(token) && token.length >=
SEARCH_TEXT_MIN_TOKEN_LENGTH) {
+ tokens.add(token);
+ }
+ }
+ return tokens;
+}
+
+// A service name collides with another provider's own identity when every
+// one of its (stopword-stripped, alias-normalised) tokens appears together in
+// a single category string of some *other* provider -- e.g. "AWS Bedrock"
+// collides with Amazon's own "Amazon Bedrock" category. Pagefind normalises
+// scores by document length, so a bare mention of a colliding name in Common
+// AI's much shorter record can outrank the provider that actually implements
+// it; single-token bare vendor names (e.g. "OpenAI", "Anthropic") are never
+// eligible for collision, since kaxil's own numbers show those queries are
+// unaffected and the provider they'd "collide" with is themselves.
+function findSearchTextCollision(serviceName, provider, allProviders) {
+ const serviceTokens = tokenizeForCollisionCheck(serviceName);
+ if (serviceTokens.size < 2) {
+ return false;
+ }
+ for (const otherProvider of allProviders) {
+ if (otherProvider.id === provider.id) {
+ continue;
+ }
+ for (const category of otherProvider.categories || []) {
+ const categoryTokens = tokenizeForCollisionCheck(category.name || '');
+ let allPresent = true;
+ for (const token of serviceTokens) {
+ if (!categoryTokens.has(token)) {
+ allPresent = false;
+ break;
+ }
+ }
+ if (allPresent) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+// Excludes service names from search-indexed text when they collide with
+// another provider's own category (see findSearchTextCollision); the caller
+// still gets the full, unfiltered list for the badge and the /providers/
+// filter (collectExternalServices() itself is untouched).
+// A collision can only be seen against the providers in `allProviders`, so a
+// partial dataset filters less than a full one; the index is built after the
+// merge step, where the set is complete.
+function filterServicesForSearchText(services, provider, allProviders) {
+ return services.filter((service) => !findSearchTextCollision(service,
provider, allProviders));
+}
+
+module.exports = { collectExternalServices, filterServicesForSearchText };
diff --git a/registry/src/_data/providerKeywordMatch.js
b/registry/src/_data/providerKeywordMatch.js
index 8f3bfd4f5bf..77fa2c025cc 100644
--- a/registry/src/_data/providerKeywordMatch.js
+++ b/registry/src/_data/providerKeywordMatch.js
@@ -22,15 +22,21 @@
// respectively by the category dropdown on /providers/ and the Explore landing
// page's per-category provider listing) can't drift apart.
-// A value "matches" a keyword if the value contains the keyword,
-// case-insensitively, after collapsing runs of "-_\s" to a single space
-// (so 'pydantic-ai' matches "Pydantic AI"). Collapse, don't strip:
-// stripping would turn "Microsoft Power BI" into "microsoftpowerbi",
-// which contains "ftp" and would falsely match the orchestration category.
+const { collectExternalServices } = require("./providerExternalServices");
+
+// Kept in sync by hand with normalize() in src/js/search.js and
+// src/js/provider-filters.js (both browser IIFEs; this file runs at build
+// time under CommonJS). Collapse runs of "-_\s" to a space rather than
+// strip: stripping "Microsoft Power BI" would turn it into
+// "microsoftpowerbi", which contains "ftp" and would falsely match
+// the orchestration category.
function normalize(text) {
return text.toLowerCase().replace(/[-_\s]+/g, ' ');
}
+// A value "matches" a keyword if the value contains the keyword,
+// case-insensitively, after collapsing runs of "-_\s" to a single
+// space (so 'pydantic-ai' matches "Pydantic AI").
function fuzzyIncludes(value, keyword) {
if (!value) {
return false;
@@ -39,20 +45,30 @@ function fuzzyIncludes(value, keyword) {
}
// Every string a provider is searchable by: its id/slug and its declared
-// integration names (provider.categories[].name — e.g. "LangChain",
-// "Pydantic AI").
-function collectSearchableValues(provider) {
+// integration names (provider.categories[].name) are always included.
+// Its declared `external_services` are included only when the caller opts
+// in via `{ includeExternalServices: true }` — see exploreCategories.js
+// for which category does and why.
+function collectSearchableValues(provider, { includeExternalServices = false }
= {}) {
const values = [provider.id];
for (const category of provider.categories || []) {
if (category.name) {
values.push(category.name);
}
}
+ if (includeExternalServices) {
+ values.push(...collectExternalServices(provider));
+ }
return values;
}
-function providerMatchesKeyword(provider, keyword) {
- return collectSearchableValues(provider).some((value) =>
fuzzyIncludes(value, keyword));
+function providerMatchesKeyword(provider, keyword, options = {}) {
+ return collectSearchableValues(provider, options).some((value) =>
fuzzyIncludes(value, keyword));
+}
+
+function categoryMatchesProvider(category, provider) {
+ const options = { includeExternalServices: category.includeExternalServices
=== true };
+ return category.keywords.some((keyword) => providerMatchesKeyword(provider,
keyword, options));
}
-module.exports = { providerMatchesKeyword };
+module.exports = { categoryMatchesProvider };
diff --git a/registry/src/css/main.css b/registry/src/css/main.css
index ef006000248..1ea29a44c7c 100644
--- a/registry/src/css/main.css
+++ b/registry/src/css/main.css
@@ -4287,6 +4287,13 @@ main {
border: 1px solid rgb(from var(--color-notifier) r g b / 0.3);
}
+#search-results .badge.service {
+ background: rgb(from var(--color-cyan-500) r g b / 0.2);
+ color: var(--color-cyan-400);
+ border: 1px solid rgb(from var(--color-cyan-500) r g b / 0.3);
+ text-transform: none;
+}
+
#search-results .provider > span:first-child {
background: rgb(from var(--color-cyan-500) r g b / 0.2);
color: var(--color-cyan-400);
diff --git a/registry/src/js/provider-filters.js
b/registry/src/js/provider-filters.js
index a143003acf1..d3e230e6642 100644
--- a/registry/src/js/provider-filters.js
+++ b/registry/src/js/provider-filters.js
@@ -33,10 +33,10 @@
let currentSearch = '';
let debounceTimer;
- // Kept in sync by hand with normalize() in
src/_data/providerKeywordMatch.js:
- // that module runs at build time under CommonJS and this file is a browser
- // IIFE, so the two can't share it. If the two drift, a query like
- // 'pydantic-ai' stops finding the integration named "Pydantic AI".
+ // Kept in sync by hand with normalize() in search.js and
+ // src/_data/providerKeywordMatch.js (this file and search.js are browser
+ // IIFEs; providerKeywordMatch runs at build time under CommonJS). If they
+ // drift, 'pydantic-ai' stops finding the integration named "Pydantic AI".
function normalize(text) {
return text.toLowerCase().replace(/[-_\s]+/g, ' ');
}
@@ -62,11 +62,13 @@
const id = item.dataset.id || '';
const categories = item.dataset.categories || '';
const integrations = item.dataset.integrations || '';
+ const services = item.dataset.services || '';
const matchesLifecycle = currentLifecycle === 'all' || lifecycle ===
currentLifecycle;
const matchesSearch = normalize(name).includes(search) ||
normalize(id).includes(search) ||
- normalize(integrations).includes(search);
+ normalize(integrations).includes(search) ||
+ normalize(services).includes(search);
const matchesCategory = currentCategory === 'all' ||
categories.split(',').includes(currentCategory);
diff --git a/registry/src/js/search.js b/registry/src/js/search.js
index efcff79f0eb..84bb675d816 100644
--- a/registry/src/js/search.js
+++ b/registry/src/js/search.js
@@ -20,6 +20,7 @@
(function() {
let pagefind = null;
let currentFilter = 'all';
+ let currentQuery = '';
let selectedIndex = 0;
let currentResults = [];
let searchId = 0;
@@ -39,6 +40,15 @@
// Fallback: empty objects — badges show raw type name, icons fall back to
first letter
}
+ // Kept in sync by hand with normalize() in provider-filters.js and
+ // src/_data/providerKeywordMatch.js (this file and provider-filters.js are
+ // browser IIFEs; providerKeywordMatch runs at build time under CommonJS),
+ // so none of the three can share an implementation. If they drift,
+ // 'mistral-ai' stops matching "Mistral AI".
+ function normalize(text) {
+ return text.toLowerCase().replace(/[-_\s]+/g, ' ');
+ }
+
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
@@ -110,6 +120,27 @@
const icon = type === 'provider' ? 'P' : (moduleType ?
(typeIcons[moduleType] || moduleType[0].toUpperCase()) : 'M');
const resultType = type === 'provider' ? 'provider' : moduleType;
+ // A provider often matches because of an external service its
connections
+ // reach (e.g. "anthropic" -> Common AI), not the provider's own name --
+ // badge the specific service so that's visible instead of just the
+ // provider it lives under.
+ let matchedService = '';
+ if (type === 'provider' && currentQuery) {
+ const normalizedQuery = normalize(currentQuery);
+ // Skip the service lookup when the query already matches the
+ // provider's own name/id -- otherwise an unrelated service that
+ // happens to share the same substring gets badged instead.
+ const selfText = normalize(name + ' ' + (result.meta.providerId ||
''));
+ if (!selfText.includes(normalizedQuery)) {
+ const services = (result.meta.externalServices ||
'').split(',').filter(Boolean);
+ matchedService = services.find((service) => {
+ const normalizedService = normalize(service);
+ return normalizedService.startsWith(normalizedQuery) ||
+ normalizedService.includes(' ' + normalizedQuery);
+ }) || '';
+ }
+ }
+
return `
<a href="${escapeHtml(result.url)}"
class="${escapeHtml(resultType)}${index === selectedIndex ? ' selected' : ''}"
data-index="${index}">
<span>${escapeHtml(icon)}</span>
@@ -117,6 +148,7 @@
<div>
${escapeHtml(name)}
${moduleType ? `<span class="badge
${escapeHtml(moduleType)}">${escapeHtml(typeLabels[moduleType] ||
moduleType)}</span>` : ''}
+ ${matchedService ? `<span class="badge
service">${escapeHtml(matchedService)}</span>` : ''}
</div>
<div>
${providerName ? `<span><svg width="12" height="12" fill="none"
stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0
01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0
0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
${escapeHtml(providerName)}</span>` : ''}
@@ -168,6 +200,7 @@
input.addEventListener('input', async (e) => {
const query = e.target.value;
+ currentQuery = query.trim();
const thisSearchId = ++searchId;
if (!query.trim()) {
@@ -212,6 +245,7 @@
currentFilter = tab.dataset.filter;
const query = input.value;
+ currentQuery = query.trim();
if (query.trim()) {
const results = await performSearch(query);
renderResults(results, true);
@@ -223,6 +257,7 @@
modal.classList.add('active');
input.value = '';
input.focus();
+ currentQuery = '';
currentResults = [];
selectedIndex = 0;
resultsContainer.innerHTML = `
@@ -240,6 +275,7 @@
function closeModal() {
modal.classList.remove('active');
input.value = '';
+ currentQuery = '';
currentResults = [];
selectedIndex = 0;
}
diff --git a/registry/src/providers.njk b/registry/src/providers.njk
index 5668cf98e2c..15637ee5fc3 100644
--- a/registry/src/providers.njk
+++ b/registry/src/providers.njk
@@ -70,7 +70,8 @@ mainClass: providers-page
data-downloads="{{ provider.pypi_downloads.monthly }}"
data-updated="{{ provider.last_updated or '' }}"
data-categories="{{ cats | join(',') }}"
- data-integrations="{{ (provider.categories or []) |
selectattr('name') | join(',', 'name') | lower }}">
+ data-integrations="{{ (provider.categories or []) |
selectattr('name') | join(',', 'name') | lower }}"
+ data-services="{{ provider | externalServices | join(',') | lower
}}">
{% include "provider-card.njk" %}
</li>
{% endfor %}