diff --git a/.github/workflows/sync-logos.yml b/.github/workflows/sync-logos.yml new file mode 100644 index 00000000..c572c9b5 --- /dev/null +++ b/.github/workflows/sync-logos.yml @@ -0,0 +1,59 @@ +name: Sync Service Logos + +on: + push: + branches: [main] + paths: [schemas/discovery.json] + schedule: + - cron: "0 6 * * 1" # Every Monday at 06:00 UTC + workflow_dispatch: + +jobs: + sync: + name: Sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - name: Generate discovery.json + run: node scripts/generate-discovery.ts + - name: Sync logos to Vercel Blob + id: sync + run: | + OUTPUT=$(node scripts/sync-logos.ts 2>&1) + echo "$OUTPUT" + SUMMARY=$(echo "$OUTPUT" | tail -1) + echo "summary=$SUMMARY" >> "$GITHUB_OUTPUT" + env: + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + LOGODEV_PUBLIC_KEY: ${{ secrets.LOGODEV_PUBLIC_KEY }} + - name: Post job summary + if: always() + run: | + SUMMARY='${{ steps.sync.outputs.summary }}' + FAILED=$(echo "$SUMMARY" | jq -r '.failed // 0') + SYNCED=$(echo "$SUMMARY" | jq -r '.synced // 0') + TOTAL=$(echo "$SUMMARY" | jq -r '.total // 0') + PLACEHOLDERS=$(echo "$SUMMARY" | jq -r '.placeholders // 0') + echo "## 🖼️ Logo Sync Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Metric | Count |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Total services | $TOTAL |" >> "$GITHUB_STEP_SUMMARY" + echo "| ✅ Synced | $SYNCED |" >> "$GITHUB_STEP_SUMMARY" + echo "| ⚠️ Placeholders | $PLACEHOLDERS |" >> "$GITHUB_STEP_SUMMARY" + echo "| ❌ Failed | $FAILED |" >> "$GITHUB_STEP_SUMMARY" + - name: Fail if icons failed + if: always() + run: | + SUMMARY='${{ steps.sync.outputs.summary }}' + FAILED=$(echo "$SUMMARY" | jq -r '.failed // 0') + if [ "$FAILED" -gt 0 ]; then + echo "::error::$FAILED icon(s) failed to sync" + exit 1 + fi diff --git a/public/icons/agentmail.svg b/public/icons/agentmail.svg index d00c65f9..6c8f73ed 100644 --- a/public/icons/agentmail.svg +++ b/public/icons/agentmail.svg @@ -1 +1 @@ - + diff --git a/public/icons/alchemy.svg b/public/icons/alchemy.svg index a6c357eb..40595e5a 100644 --- a/public/icons/alchemy.svg +++ b/public/icons/alchemy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/allium.svg b/public/icons/allium.svg index ae7cd9d7..fe5f01ff 100644 --- a/public/icons/allium.svg +++ b/public/icons/allium.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/anthropic.svg b/public/icons/anthropic.svg index 6f481ffc..718ae234 100644 --- a/public/icons/anthropic.svg +++ b/public/icons/anthropic.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/aviationstack.svg b/public/icons/aviationstack.svg index c5877353..9cd02d12 100644 --- a/public/icons/aviationstack.svg +++ b/public/icons/aviationstack.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/browser-use.svg b/public/icons/browser-use.svg index c5653e9a..ac20530e 100644 --- a/public/icons/browser-use.svg +++ b/public/icons/browser-use.svg @@ -1 +1 @@ -B \ No newline at end of file +B \ No newline at end of file diff --git a/public/icons/browserbase.svg b/public/icons/browserbase.svg index d85ebcbc..915294b3 100644 --- a/public/icons/browserbase.svg +++ b/public/icons/browserbase.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/builtwith.svg b/public/icons/builtwith.svg index c5653e9a..ac20530e 100644 --- a/public/icons/builtwith.svg +++ b/public/icons/builtwith.svg @@ -1 +1 @@ -B \ No newline at end of file +B \ No newline at end of file diff --git a/public/icons/clado.svg b/public/icons/clado.svg index 6397c9fe..eeb0bba5 100644 --- a/public/icons/clado.svg +++ b/public/icons/clado.svg @@ -1 +1 @@ -C \ No newline at end of file +C \ No newline at end of file diff --git a/public/icons/codestorage.svg b/public/icons/codestorage.svg index 5ed44de4..b512095a 100644 --- a/public/icons/codestorage.svg +++ b/public/icons/codestorage.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/codex.svg b/public/icons/codex.svg index 635eded1..1f200316 100644 --- a/public/icons/codex.svg +++ b/public/icons/codex.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/diffbot.svg b/public/icons/diffbot.svg index 4e7b94c5..b2a47724 100644 --- a/public/icons/diffbot.svg +++ b/public/icons/diffbot.svg @@ -1 +1 @@ -D \ No newline at end of file +D \ No newline at end of file diff --git a/public/icons/digitalocean.svg b/public/icons/digitalocean.svg index d881184d..1299637e 100644 --- a/public/icons/digitalocean.svg +++ b/public/icons/digitalocean.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/dune.svg b/public/icons/dune.svg index 11526cc4..a13c963e 100644 --- a/public/icons/dune.svg +++ b/public/icons/dune.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/edgar-search.svg b/public/icons/edgar-search.svg index c3759c30..f39df771 100644 --- a/public/icons/edgar-search.svg +++ b/public/icons/edgar-search.svg @@ -1 +1 @@ -E \ No newline at end of file +E \ No newline at end of file diff --git a/public/icons/edgar.svg b/public/icons/edgar.svg index c3759c30..f39df771 100644 --- a/public/icons/edgar.svg +++ b/public/icons/edgar.svg @@ -1 +1 @@ -E \ No newline at end of file +E \ No newline at end of file diff --git a/public/icons/exa.svg b/public/icons/exa.svg index d04f2bfd..89951d3b 100644 --- a/public/icons/exa.svg +++ b/public/icons/exa.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/fal.svg b/public/icons/fal.svg index 5c02f15f..886baad5 100644 --- a/public/icons/fal.svg +++ b/public/icons/fal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/firecrawl.svg b/public/icons/firecrawl.svg index 6907d7c5..96b8a685 100644 --- a/public/icons/firecrawl.svg +++ b/public/icons/firecrawl.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/flightapi.svg b/public/icons/flightapi.svg index eb0cb894..1f35b8e3 100644 --- a/public/icons/flightapi.svg +++ b/public/icons/flightapi.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/gemini.svg b/public/icons/gemini.svg index c3cf32ff..020c554e 100644 --- a/public/icons/gemini.svg +++ b/public/icons/gemini.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/goflightlabs.svg b/public/icons/goflightlabs.svg index 89c7ef3e..1c6dfa4f 100644 --- a/public/icons/goflightlabs.svg +++ b/public/icons/goflightlabs.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-aerialview.svg b/public/icons/googlemaps-aerialview.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-aerialview.svg +++ b/public/icons/googlemaps-aerialview.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-airquality.svg b/public/icons/googlemaps-airquality.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-airquality.svg +++ b/public/icons/googlemaps-airquality.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-geolocation.svg b/public/icons/googlemaps-geolocation.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-geolocation.svg +++ b/public/icons/googlemaps-geolocation.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-places-v2.svg b/public/icons/googlemaps-places-v2.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-places-v2.svg +++ b/public/icons/googlemaps-places-v2.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-pollen.svg b/public/icons/googlemaps-pollen.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-pollen.svg +++ b/public/icons/googlemaps-pollen.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-roads.svg b/public/icons/googlemaps-roads.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-roads.svg +++ b/public/icons/googlemaps-roads.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-routes.svg b/public/icons/googlemaps-routes.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-routes.svg +++ b/public/icons/googlemaps-routes.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-solar.svg b/public/icons/googlemaps-solar.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-solar.svg +++ b/public/icons/googlemaps-solar.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-tiles.svg b/public/icons/googlemaps-tiles.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-tiles.svg +++ b/public/icons/googlemaps-tiles.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-validation.svg b/public/icons/googlemaps-validation.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-validation.svg +++ b/public/icons/googlemaps-validation.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps-weather.svg b/public/icons/googlemaps-weather.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps-weather.svg +++ b/public/icons/googlemaps-weather.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/googlemaps.svg b/public/icons/googlemaps.svg index 3e648350..731492d9 100644 --- a/public/icons/googlemaps.svg +++ b/public/icons/googlemaps.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/hunter.svg b/public/icons/hunter.svg index 8c41e70b..88a8e501 100644 --- a/public/icons/hunter.svg +++ b/public/icons/hunter.svg @@ -1 +1 @@ -H \ No newline at end of file +H \ No newline at end of file diff --git a/public/icons/judge0.svg b/public/icons/judge0.svg index 9ae294e1..1ebb029f 100644 --- a/public/icons/judge0.svg +++ b/public/icons/judge0.svg @@ -1 +1 @@ -J \ No newline at end of file +J \ No newline at end of file diff --git a/public/icons/kicksdb.svg b/public/icons/kicksdb.svg index 8224f6d5..667a121b 100644 --- a/public/icons/kicksdb.svg +++ b/public/icons/kicksdb.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/laso.svg b/public/icons/laso.svg index cd907823..b565e9bf 100644 --- a/public/icons/laso.svg +++ b/public/icons/laso.svg @@ -1 +1 @@ -L \ No newline at end of file +L \ No newline at end of file diff --git a/public/icons/mapbox.svg b/public/icons/mapbox.svg index 79660ad7..ba147a8f 100644 --- a/public/icons/mapbox.svg +++ b/public/icons/mapbox.svg @@ -1 +1 @@ -M \ No newline at end of file +M \ No newline at end of file diff --git a/public/icons/mathpix.svg b/public/icons/mathpix.svg index 79660ad7..ba147a8f 100644 --- a/public/icons/mathpix.svg +++ b/public/icons/mathpix.svg @@ -1 +1 @@ -M \ No newline at end of file +M \ No newline at end of file diff --git a/public/icons/modal.svg b/public/icons/modal.svg index 21a8c394..daeda132 100644 --- a/public/icons/modal.svg +++ b/public/icons/modal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/openai.svg b/public/icons/openai.svg index b4839440..07e33207 100644 --- a/public/icons/openai.svg +++ b/public/icons/openai.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/openrouter.svg b/public/icons/openrouter.svg index e54129b5..9f0fe6f0 100644 --- a/public/icons/openrouter.svg +++ b/public/icons/openrouter.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/openweather.svg b/public/icons/openweather.svg index f8aae7c1..766da094 100644 --- a/public/icons/openweather.svg +++ b/public/icons/openweather.svg @@ -1 +1 @@ -O \ No newline at end of file +O \ No newline at end of file diff --git a/public/icons/oxylabs.svg b/public/icons/oxylabs.svg index 830425b2..547685fb 100644 --- a/public/icons/oxylabs.svg +++ b/public/icons/oxylabs.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/parallel.svg b/public/icons/parallel.svg index 97fe9a0f..b43295c4 100644 --- a/public/icons/parallel.svg +++ b/public/icons/parallel.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/perplexity.svg b/public/icons/perplexity.svg index 9ab6ffe6..cedfd832 100644 --- a/public/icons/perplexity.svg +++ b/public/icons/perplexity.svg @@ -1 +1 @@ -P \ No newline at end of file +P \ No newline at end of file diff --git a/public/icons/postalform.svg b/public/icons/postalform.svg index a37f668d..088b96c2 100644 --- a/public/icons/postalform.svg +++ b/public/icons/postalform.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/prospect-butcher.svg b/public/icons/prospect-butcher.svg index 99f6b3da..3b83a062 100644 --- a/public/icons/prospect-butcher.svg +++ b/public/icons/prospect-butcher.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/rentcast.svg b/public/icons/rentcast.svg index 71c4d653..959b2f52 100644 --- a/public/icons/rentcast.svg +++ b/public/icons/rentcast.svg @@ -1 +1 @@ -R \ No newline at end of file +R \ No newline at end of file diff --git a/public/icons/replicate.svg b/public/icons/replicate.svg index 71c4d653..959b2f52 100644 --- a/public/icons/replicate.svg +++ b/public/icons/replicate.svg @@ -1 +1 @@ -R \ No newline at end of file +R \ No newline at end of file diff --git a/public/icons/rpc.svg b/public/icons/rpc.svg index b8f129fc..7340fa41 100644 --- a/public/icons/rpc.svg +++ b/public/icons/rpc.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/serpapi.svg b/public/icons/serpapi.svg index 2ccd8735..febece82 100644 --- a/public/icons/serpapi.svg +++ b/public/icons/serpapi.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/spyfu.svg b/public/icons/spyfu.svg index 694a2baf..f4451d8d 100644 --- a/public/icons/spyfu.svg +++ b/public/icons/spyfu.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stability-ai.svg b/public/icons/stability-ai.svg index cc0f3225..ecce3ff8 100644 --- a/public/icons/stability-ai.svg +++ b/public/icons/stability-ai.svg @@ -1 +1 @@ -S \ No newline at end of file +S \ No newline at end of file diff --git a/public/icons/stableemail.svg b/public/icons/stableemail.svg index 93af4fcb..a1c140bc 100644 --- a/public/icons/stableemail.svg +++ b/public/icons/stableemail.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stableenrich.svg b/public/icons/stableenrich.svg index 986c825f..11d02e80 100644 --- a/public/icons/stableenrich.svg +++ b/public/icons/stableenrich.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stablephone.svg b/public/icons/stablephone.svg index 902950b3..a4a73727 100644 --- a/public/icons/stablephone.svg +++ b/public/icons/stablephone.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stablesocial.svg b/public/icons/stablesocial.svg index 500846b8..a5757150 100644 --- a/public/icons/stablesocial.svg +++ b/public/icons/stablesocial.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stablestudio.svg b/public/icons/stablestudio.svg index 612dd92c..21155eb2 100644 --- a/public/icons/stablestudio.svg +++ b/public/icons/stablestudio.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stabletravel.svg b/public/icons/stabletravel.svg index bc043da2..b7781200 100644 --- a/public/icons/stabletravel.svg +++ b/public/icons/stabletravel.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stableupload.svg b/public/icons/stableupload.svg index d14c6bde..267085a9 100644 --- a/public/icons/stableupload.svg +++ b/public/icons/stableupload.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/storage.svg b/public/icons/storage.svg index b8f129fc..7340fa41 100644 --- a/public/icons/storage.svg +++ b/public/icons/storage.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/stripe-climate.svg b/public/icons/stripe-climate.svg index 2278e7ba..c798c96d 100644 --- a/public/icons/stripe-climate.svg +++ b/public/icons/stripe-climate.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/suno.svg b/public/icons/suno.svg index cc0f3225..ecce3ff8 100644 --- a/public/icons/suno.svg +++ b/public/icons/suno.svg @@ -1 +1 @@ -S \ No newline at end of file +S \ No newline at end of file diff --git a/public/icons/twitter.svg b/public/icons/twitter.svg index 6c9879b1..68d07060 100644 --- a/public/icons/twitter.svg +++ b/public/icons/twitter.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/icons/twocaptcha.svg b/public/icons/twocaptcha.svg index d171c5c2..f99f72c5 100644 --- a/public/icons/twocaptcha.svg +++ b/public/icons/twocaptcha.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/scripts/gen-icons.cjs b/scripts/gen-icons.cjs deleted file mode 100644 index 9d6ac9c8..00000000 --- a/scripts/gen-icons.cjs +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Generate service icons for public/icons/. - * - * Three tiers of icons (in priority order): - * 1. Hand-coded SVG vector paths (defined in `icons` below) - * 2. Brand logos fetched from brand.dev (when BRANDDEV_LOGOLINK_KEY is set) - * 3. Letter-fallback SVGs (single uppercase letter) - * - * Usage: - * node scripts/gen-icons.cjs # generate all icons - * node scripts/gen-icons.cjs --strict # fail if any letter fallbacks remain - * BRANDDEV_LOGOLINK_KEY=... node scripts/gen-icons.cjs # fetch missing brand icons - * - * The --strict flag is used in CI to ensure all services have a real icon - * (vector or brand) checked into git — no letter fallbacks allowed. - */ - -const fs = require("node:fs"); -const path = require("node:path"); - -const strict = process.argv.includes("--strict"); -const LOGOLINK_KEY = process.env.BRANDDEV_LOGOLINK_KEY; - -const iconsDir = path.join(__dirname, "..", "public", "icons"); - -// --------------------------------------------------------------------------- -// SVG generators -// --------------------------------------------------------------------------- - -function svg(viewBox, paths) { - const [minX, minY, vbW, vbH] = viewBox.split(/\s+/).map(Number); - const inner = 358.4; - const scale = inner / Math.max(vbW, vbH); - const iconW = vbW * scale; - const iconH = vbH * scale; - const tx = (76.8 + (inner - iconW) / 2 - minX * scale).toFixed(2); - const ty = (76.8 + (inner - iconH) / 2 - minY * scale).toFixed(2); - const s = scale.toFixed(6); - - const arr = Array.isArray(paths) ? paths : [paths]; - const d = arr.map((p) => ``).join(""); - - return [ - ``, - ``, - ``, - d, - ``, - ].join(""); -} - -function letterSvg(letter) { - return [ - ``, - ``, - `${letter}`, - ``, - ].join(""); -} - -function brandSvg(logoDataUri) { - return [ - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ].join(""); -} - -// --------------------------------------------------------------------------- -// Hand-coded vector icons -// --------------------------------------------------------------------------- - -const icons = { - alchemy: [ - "0 0 24 24", - "M12.0059 1.7635a.4383.4383 0 0 0-.2149.0547.4214.4214 0 0 0-.1562.1523L9.3613 5.834a.8191.8191 0 0 0-.1133.416c0 .146.0388.2896.1133.416l4.9512 8.4123a.8444.8444 0 0 0 .3125.3047.8584.8584 0 0 0 .4239.1113h4.5489a.4358.4358 0 0 0 .2129-.0566.4185.4185 0 0 0 .1543-.1524.4106.4106 0 0 0 .0586-.207.416.416 0 0 0-.0567-.209L12.3711 1.9745a.416.416 0 0 0-.1543-.1524.4276.4276 0 0 0-.211-.0586zM8.0195 8.5058a.4277.4277 0 0 0-.211.0566.4235.4235 0 0 0-.1562.1524L.0584 21.6095a.4083.4083 0 0 0-.002.418.4188.4188 0 0 0 .1563.1524c.065.0365.138.057.2129.0566h4.5509a.8586.8586 0 0 0 .4238-.1113.8389.8389 0 0 0 .3105-.3047l4.9532-8.4123a.8194.8194 0 0 0 .1133-.416.8264.8264 0 0 0-.1133-.418L8.3886 8.7148a.4235.4235 0 0 0-.1562-.1524.435.435 0 0 0-.213-.0566Zm3.0117 8.8244a.8645.8645 0 0 0-.4258.1113.8385.8385 0 0 0-.3105.3047l-2.2754 3.8614a.4123.4123 0 0 0-.0567.209.4059.4059 0 0 0 .0567.207.4228.4228 0 0 0 .1543.1543.432.432 0 0 0 .2129.0547h15.1897a.4319.4319 0 0 0 .2129-.0547.4222.4222 0 0 0 .1543-.1543.4059.4059 0 0 0 .0566-.207.4122.4122 0 0 0-.0566-.209L21.67 17.7462a.8384.8384 0 0 0-.3106-.3047.8573.8573 0 0 0-.4238-.1113z", - ], - anthropic: [ - "0 0 24 24", - "M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z", - ], - browserbase: [ - "0 0 100 100", - "M36 72.2222V27.7778H51.2381C57.5873 27.7778 62.6667 32.8571 62.6667 39.2063V41.746C62.6667 44.6667 61.5873 47.3968 59.7461 49.3651C62.2858 51.4603 63.9366 54.6349 63.9366 58.254V60.7936C63.9366 67.1428 58.8572 72.2222 52.508 72.2222H36ZM42.3493 65.873H52.508C55.3651 65.873 57.5873 63.6508 57.5873 60.7936V58.254C57.5873 55.3968 55.3651 53.1746 52.508 53.1746H42.3493V65.873ZM42.3493 46.8254H51.2381C54.0953 46.8254 56.3175 44.6032 56.3175 41.746V39.2063C56.3175 36.3492 54.0953 34.127 51.2381 34.127H42.3493V46.8254Z", - ], - digitalocean: [ - "0 0 24 24", - "M12.04 0C5.408-.02.005 5.37.005 11.992h4.638c0-4.923 4.882-8.731 10.064-6.855a6.95 6.95 0 014.147 4.148c1.889 5.177-1.924 10.055-6.84 10.064v-4.61H7.391v4.623h4.61V24c7.86 0 13.967-7.588 11.397-15.83-1.115-3.59-3.985-6.446-7.575-7.575A12.8 12.8 0 0012.039 0zM7.39 19.362H3.828v3.564H7.39zm-3.563 0v-2.978H.85v2.978z", - ], - exa: [ - "0 0 24 24", - "M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z", - ], - fal: ["0 0 24 24", "M13 2L3 14h7l-2 8 10-12h-7l2-8z"], - firecrawl: [ - "0 0 24 24", - "M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z", - ], - gemini: [ - "0 0 24 24", - "M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81", - ], - modal: [ - "0 0 24 24", - "M4.89 5.57 0 14.002l2.521 4.4h5.05l4.396-7.718 4.512 7.709 4.996.037L24 14.057l-4.857-8.452-5.073-.015-2.076 3.598L9.94 5.57Zm.837.729h3.787l1.845 3.252H7.572Zm9.189.021 3.803.012 4.228 7.355-3.736-.027zm-9.82.346L6.94 9.914l-4.209 7.389-1.892-3.3Zm9.187.014 4.297 7.343-1.892 3.282-4.3-7.344zm-6.713 3.6h3.79l-4.212 7.394H3.361Zm11.64 4.109 3.74.027-1.893 3.281-3.74-.027z", - ], - openai: [ - "0 0 320 320", - "m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z", - ], - openrouter: [ - "0 0 24 24", - "M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z", - ], - parallel: [ - "0 0 271 270", - [ - "M267.804 105.65H193.828C194.026 106.814 194.187 107.996 194.349 109.178H76.6703C76.4546 110.736 76.2388 112.312 76.0591 113.87H1.63342C1.27387 116.198 0.950289 118.543 0.698608 120.925H75.3759C75.2501 122.483 75.1602 124.059 75.0703 125.617H195.949C196.003 126.781 196.057 127.962 196.093 129.144H270.68V125.384C270.195 118.651 269.242 112.061 267.804 105.65Z", - "M195.949 144.401H75.0703C75.1422 145.977 75.2501 147.535 75.3759 149.093H0.698608C0.950289 151.457 1.2559 153.802 1.63342 156.148H76.0591C76.2388 157.724 76.4366 159.282 76.6703 160.84H194.349C194.187 162.022 194.008 163.186 193.828 164.367H267.804C269.242 157.957 270.195 151.367 270.68 144.634V140.874H196.093C196.057 142.055 196.003 143.219 195.949 144.401Z", - "M190.628 179.642H80.3559C80.7514 181.218 81.1828 182.776 81.6143 184.334H9.30994C10.2448 186.715 11.2515 189.061 12.3121 191.389H83.7536C84.2749 192.965 84.7962 194.523 85.3535 196.08H185.594C185.163 197.262 184.732 198.426 184.282 199.608H254.519C258.6 192.177 261.98 184.316 264.604 176.114H191.455C191.185 177.296 190.898 178.46 190.61 179.642H190.628Z", - "M177.666 214.883H93.3352C94.1082 216.458 94.9172 218.034 95.7441 219.574H29.8756C31.8351 221.992 33.8666 224.337 35.9699 226.63H99.6632C100.598 228.205 101.551 229.781 102.522 231.321H168.498C167.761 232.503 167.006 233.685 166.233 234.849H226.762C234.474 227.847 241.36 219.95 247.292 211.355H179.356C178.799 212.537 178.26 213.719 177.684 214.883H177.666Z", - "M154.943 250.106H116.058C117.371 251.699 118.701 253.257 120.067 254.797H73.021C91.6094 264.431 112.715 269.946 135.096 270C135.24 270 135.366 270 135.492 270C135.618 270 135.761 270 135.887 270C164.04 269.911 190.178 261.28 211.805 246.56H157.748C156.813 247.742 155.878 248.924 154.925 250.088L154.943 250.106Z", - "M116.059 19.9124H154.943C155.896 21.0764 156.831 22.2582 157.766 23.4401H211.823C190.179 8.72065 164.058 0.0895344 135.906 0C135.762 0 135.636 0 135.51 0C135.384 0 135.24 0 135.115 0C112.715 0.0716275 91.6277 5.56904 73.0393 15.2029H120.086C118.719 16.7429 117.389 18.3187 116.077 19.8945L116.059 19.9124Z", - "M93.3356 55.1532H177.667C178.242 56.3171 178.799 57.499 179.339 58.6808H247.274C241.342 50.0855 234.457 42.1886 226.744 35.187H166.215C166.988 36.351 167.743 37.5328 168.48 38.7147H102.504C101.533 40.2726 100.58 41.8305 99.6456 43.4063H35.9523C33.831 45.6804 31.7996 48.0262 29.858 50.4616H95.7265C94.8996 52.0195 94.1086 53.5774 93.3176 55.1532H93.3356Z", - "M80.3736 90.3758H190.646C190.933 91.5398 191.221 92.7216 191.491 93.9035H264.64C262.015 85.7021 258.636 77.841 254.555 70.4097H184.318C184.767 71.5736 185.199 72.7555 185.63 73.9373H85.3893C84.832 75.4952 84.2927 77.0531 83.7893 78.6289H12.3479C11.2872 80.9389 10.2805 83.2847 9.3457 85.6842H81.65C81.2186 87.2421 80.7871 88.8 80.3916 90.3758H80.3736Z", - ], - ], - rpc: [ - "810 386 300 308", - "M931.47,640h-54.67l50.67-154.67h-64.8l14.13-45.33h180.53l-14.13,45.33h-61.33l-50.4,154.67Z", - ], - storage: [ - "810 386 300 308", - "M931.47,640h-54.67l50.67-154.67h-64.8l14.13-45.33h180.53l-14.13,45.33h-61.33l-50.4,154.67Z", - ], - twitter: [ - "0 0 24 24", - "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z", - ], -}; - -// --------------------------------------------------------------------------- -// Google Maps icons (all share the same path) -// --------------------------------------------------------------------------- - -const GMAPS_VB = "0 0 24 24"; -const GMAPS_PATH = - "M19.527 4.799c1.212 2.608.937 5.678-.405 8.173-1.101 2.047-2.744 3.74-4.098 5.614-.619.858-1.244 1.75-1.669 2.727-.141.325-.263.658-.383.992-.121.333-.224.673-.34 1.008-.109.314-.236.684-.627.687h-.007c-.466-.001-.579-.53-.695-.887-.284-.874-.581-1.713-1.019-2.525-.51-.944-1.145-1.817-1.79-2.671L19.527 4.799zM8.545 7.705l-3.959 4.707c.724 1.54 1.821 2.863 2.871 4.18.247.31.494.622.737.936l4.984-5.925-.029.01c-1.741.601-3.691-.291-4.392-1.987a3.377 3.377 0 0 1-.209-.716c-.063-.437-.077-.761-.004-1.198l.001-.007zM5.492 3.149l-.003.004c-1.947 2.466-2.281 5.88-1.117 8.77l4.785-5.689-.058-.05-3.607-3.035zM14.661.436l-3.838 4.563a.295.295 0 0 1 .027-.01c1.6-.551 3.403.15 4.22 1.626.176.319.323.683.377 1.045.068.446.085.773.012 1.22l-.003.016 3.836-4.561A8.382 8.382 0 0 0 14.67.439l-.009-.003zM9.466 5.868L14.162.285l-.047-.012A8.31 8.31 0 0 0 11.986 0a8.439 8.439 0 0 0-6.169 2.766l-.016.018 3.665 3.084z"; - -const gmapsServices = [ - "googlemaps", - "googlemaps-aerialview", - "googlemaps-airquality", - "googlemaps-geolocation", - "googlemaps-places-v2", - "googlemaps-pollen", - "googlemaps-roads", - "googlemaps-routes", - "googlemaps-solar", - "googlemaps-tiles", - "googlemaps-validation", - "googlemaps-weather", -]; - -// Override map for services whose first letter isn't a good icon -const letterOverrides = { - "prospect-butcher": "P", - stableemail: "E", - stableenrich: "E", - stablephone: "P", - stabletravel: "T", - stableupload: "U", - twocaptcha: "2", -}; - -// --------------------------------------------------------------------------- -// brand.dev fetching -// --------------------------------------------------------------------------- - -function domainFor(service) { - const raw = service.provider?.url || service.url; - try { - return new URL(raw).hostname; - } catch { - return null; - } -} - -async function fetchBrandIcon(domain) { - try { - const url = `https://logos.brand.dev/?publicClientId=${encodeURIComponent(LOGOLINK_KEY)}&domain=${domain}`; - const res = await fetch(url); - if (!res.ok) return null; - const ct = res.headers.get("content-type") || "image/png"; - const buf = await res.arrayBuffer(); - if (buf.byteLength < 100) return null; - return `data:${ct};base64,${Buffer.from(buf).toString("base64")}`; - } catch (e) { - console.error(` brand.dev error for ${domain}: ${e.message}`); - return null; - } -} - -const DOMAIN_OVERRIDES = { - codex: "codex.io", -}; - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main() { - const hasIcon = new Set([...Object.keys(icons), ...gmapsServices]); - const discoveryPath = path.join(__dirname, "..", "schemas", "discovery.json"); - const services = fs.existsSync(discoveryPath) - ? JSON.parse(fs.readFileSync(discoveryPath, "utf-8")).services || [] - : []; - const serviceIds = services.map((s) => s.id); - - // --- Tier 1: write hand-coded vector icons --- - let n = 0; - for (const [name, [vb, paths]] of Object.entries(icons)) { - fs.writeFileSync(path.join(iconsDir, `${name}.svg`), svg(vb, paths)); - n++; - } - for (const name of gmapsServices) { - fs.writeFileSync( - path.join(iconsDir, `${name}.svg`), - svg(GMAPS_VB, GMAPS_PATH), - ); - n++; - } - - // --- Determine which services still need icons --- - const needsIcon = []; - for (const id of new Set(serviceIds)) { - if (hasIcon.has(id)) continue; - const existing = path.join(iconsDir, `${id}.svg`); - if (fs.existsSync(existing)) { - const content = fs.readFileSync(existing, "utf-8"); - if (!content.includes(" 0) { - const serviceMap = new Map(services.map((s) => [s.id, s])); - const domainCache = new Map(); - - console.log(`Fetching brand icons for ${needsIcon.length} services...`); - - for (const id of [...needsIcon]) { - const service = serviceMap.get(id); - if (!service) continue; - - const domain = DOMAIN_OVERRIDES[id] || domainFor(service); - if (!domain) { - console.log(` skip ${id} (no domain)`); - brandFailed++; - continue; - } - - let result; - if (domainCache.has(domain)) { - result = domainCache.get(domain); - } else { - result = await fetchBrandIcon(domain); - domainCache.set(domain, result); - } - - if (!result) { - console.log(` fail ${id} (${domain})`); - brandFailed++; - continue; - } - - fs.writeFileSync(path.join(iconsDir, `${id}.svg`), brandSvg(result)); - console.log(` done ${id} (${domain})`); - needsIcon.splice(needsIcon.indexOf(id), 1); - brandFetched++; - n++; - } - - console.log(`Brand icons: ${brandFetched} fetched, ${brandFailed} failed`); - } - - // --- Tier 3: letter fallbacks for anything remaining --- - let letterCount = 0; - for (const id of needsIcon) { - const letter = letterOverrides[id] || id[0].toUpperCase(); - fs.writeFileSync(path.join(iconsDir, `${id}.svg`), letterSvg(letter)); - letterCount++; - n++; - } - - console.log(`Wrote ${n} icons (${letterCount} letter fallbacks)`); - - // --- Validate: every service ID must have a matching icon file --- - const missing = serviceIds.filter( - (id) => !fs.existsSync(path.join(iconsDir, `${id}.svg`)), - ); - if (missing.length > 0) { - console.error(`\nMissing icons for: ${missing.join(", ")}`); - process.exit(1); - } - - // --- Strict mode: fail if any non-allowed letter fallbacks exist --- - const letterAllowlist = new Set([]); - const strictViolations = needsIcon.filter((id) => !letterAllowlist.has(id)); - if (strict && strictViolations.length > 0) { - console.error( - `\n--strict: ${strictViolations.length} services have letter-fallback icons: ${strictViolations.join(", ")}`, - ); - console.error( - "Run with BRANDDEV_LOGOLINK_KEY to fetch brand icons, then commit the results.", - ); - process.exit(1); - } -} - -main(); diff --git a/scripts/sync-logos.ts b/scripts/sync-logos.ts new file mode 100644 index 00000000..80588fab --- /dev/null +++ b/scripts/sync-logos.ts @@ -0,0 +1,334 @@ +/** + * Syncs service logos from logo.dev → Vercel Blob. + * + * Usage: node scripts/sync-logos.ts + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { put } from "@vercel/blob"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ServiceEntry { + id: string; + name: string; + url: string; + provider?: { name?: string; url?: string }; +} + +interface Discovery { + version: number; + services: ServiceEntry[]; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const BLOB_TOKEN = process.env.BLOB_READ_WRITE_TOKEN; +if (!BLOB_TOKEN) { + console.error("BLOB_READ_WRITE_TOKEN is required"); + process.exit(1); +} + +const LOGODEV_PK = process.env.LOGODEV_PUBLIC_KEY; +if (!LOGODEV_PK) { + console.error("LOGODEV_PUBLIC_KEY is required"); + process.exit(1); +} + +const DRY_RUN = process.argv.includes("--dry-run"); +if (DRY_RUN) console.log("[dry-run] No uploads will be performed\n"); + +const DOMAIN_OVERRIDES: Record = { + stableemail: "stablestudio.dev", + stableenrich: "stablestudio.dev", + stablephone: "stablestudio.dev", + stablesocial: "stablestudio.dev", + stablestudio: "stablestudio.dev", + stabletravel: "stablestudio.dev", + stableupload: "stablestudio.dev", +}; + +// --------------------------------------------------------------------------- +// PNG pixel decoding (no external image libs) +// --------------------------------------------------------------------------- + +function decodePngPixels(buf: ArrayBuffer) { + const { inflateSync } = require("node:zlib") as typeof import("node:zlib"); + const bytes = new Uint8Array(buf); + if (bytes.length < 26) return null; + const colorType = bytes[25]; + const channels = + colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 4 ? 2 : 1; + const width = + (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; + const height = + (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; + const idatChunks: Buffer[] = []; + let offset = 8; + while (offset < bytes.length - 4) { + const len = + (bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]; + const type = String.fromCharCode( + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ); + if (type === "IDAT") + idatChunks.push(Buffer.from(bytes.slice(offset + 8, offset + 8 + len))); + offset += 12 + len; + } + if (!idatChunks.length) return null; + const raw = inflateSync(Buffer.concat(idatChunks)); + const bpp = channels; + const stride = 1 + width * bpp; + const pixels = Buffer.alloc(width * height * bpp); + const prev = Buffer.alloc(width * bpp); + for (let y = 0; y < height; y++) { + const filter = raw[y * stride]; + const rowStart = y * stride + 1; + const outStart = y * width * bpp; + for (let x = 0; x < width * bpp; x++) { + const a = x >= bpp ? pixels[outStart + x - bpp] : 0; + const b = prev[x]; + const c = x >= bpp ? (y > 0 ? prev[x - bpp] : 0) : 0; + let val = raw[rowStart + x]; + if (filter === 1) val = (val + a) & 0xff; + else if (filter === 2) val = (val + b) & 0xff; + else if (filter === 3) val = (val + ((a + b) >> 1)) & 0xff; + else if (filter === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + val = (val + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff; + } + pixels[outStart + x] = val; + } + pixels.copy(prev, 0, outStart, outStart + width * bpp); + } + return { pixels, width, height, channels }; +} + +function pngHasTransparency(buf: ArrayBuffer): boolean { + try { + const img = decodePngPixels(buf); + if (!img || (img.channels !== 4 && img.channels !== 2)) return false; + const { pixels, width, height, channels } = img; + const inset = Math.floor(Math.min(width, height) * 0.15); + const probes: [number, number][] = [ + [inset, inset], + [inset, width - 1 - inset], + [height - 1 - inset, inset], + [height - 1 - inset, width - 1 - inset], + ]; + for (const [row, col] of probes) { + const alphaIdx = (row * width + col) * channels + (channels - 1); + if (pixels[alphaIdx] < 250) return true; + } + return false; + } catch { + return false; + } +} + +function pngHasLightBg(buf: ArrayBuffer): boolean { + try { + const img = decodePngPixels(buf); + if (!img) return false; + const { pixels, width, height, channels } = img; + const inset = Math.floor(Math.min(width, height) * 0.15); + const idx = (inset * width + inset) * channels; + const r = pixels[idx], + g = pixels[idx + 1], + b = pixels[idx + 2]; + return (r + g + b) / 3 > 180; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// SVG fallback +// --------------------------------------------------------------------------- + +function letterSvg(name: string): string { + const letter = (name[0] ?? "?").toUpperCase(); + return `${letter}`; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function domainFromUrl(raw: string): string | null { + try { + return new URL(raw).hostname; + } catch { + return null; + } +} + +function domainForService(svc: ServiceEntry): string | null { + if (DOMAIN_OVERRIDES[svc.id]) return DOMAIN_OVERRIDES[svc.id]; + const raw = svc.provider?.url ?? svc.url; + return domainFromUrl(raw); +} + +function logoUrl(domain: string): string { + return `https://img.logo.dev/${domain}?token=${LOGODEV_PK}&format=png&size=256&greyscale=true&theme=dark&fallback=monogram&retina=true`; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const discoveryPath = resolve( + import.meta.dirname, + "../schemas/discovery.json", + ); + const discovery: Discovery = JSON.parse(readFileSync(discoveryPath, "utf-8")); + const services = discovery.services; + + // Domain → fetched ArrayBuffer cache (shared domains fetched once) + const domainCache = new Map(); + + let synced = 0; + let failed = 0; + let placeholders = 0; + + const transparentIds: string[] = []; + const lightBgIds: string[] = []; + + for (const svc of services) { + const domain = domainForService(svc); + + if (!domain) { + // No domain – upload letter SVG fallback + console.log(`[${svc.id}] no domain – using letter fallback`); + const svg = letterSvg(svc.name); + if (DRY_RUN) { + console.log(`[dry-run] would upload logos/${svc.id}.svg`); + } else { + await put(`logos/${svc.id}.svg`, svg, { + access: "public", + contentType: "image/svg+xml", + addRandomSuffix: false, + allowOverwrite: true, + token: BLOB_TOKEN, + }); + } + placeholders++; + continue; + } + + // Fetch logo (with domain-level caching) + let logoBuf: ArrayBuffer | null; + if (domainCache.has(domain)) { + logoBuf = domainCache.get(domain) ?? null; + } else { + try { + const res = await fetch(logoUrl(domain)); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + logoBuf = await res.arrayBuffer(); + domainCache.set(domain, logoBuf); + } catch (err) { + console.warn(`[${svc.id}] fetch failed for ${domain}:`, err); + domainCache.set(domain, null); + logoBuf = null; + } + } + + if (!logoBuf) { + // Fetch failed – upload letter SVG fallback + console.log(`[${svc.id}] logo fetch failed – using letter fallback`); + const svg = letterSvg(svc.name); + if (DRY_RUN) { + console.log(`[dry-run] would upload logos/${svc.id}.svg`); + } else { + await put(`logos/${svc.id}.svg`, svg, { + access: "public", + contentType: "image/svg+xml", + addRandomSuffix: false, + allowOverwrite: true, + token: BLOB_TOKEN, + }); + } + placeholders++; + failed++; + continue; + } + + // Analyze PNG + if (pngHasTransparency(logoBuf)) transparentIds.push(svc.id); + if (pngHasLightBg(logoBuf)) lightBgIds.push(svc.id); + + // Upload PNG to Vercel Blob + if (DRY_RUN) { + console.log(`[dry-run] would upload logos/${svc.id}.png`); + console.log(`[${svc.id}] ✓ synced (${domain})`); + synced++; + } else { + try { + await put(`logos/${svc.id}.png`, Buffer.from(logoBuf), { + access: "public", + contentType: "image/png", + addRandomSuffix: false, + allowOverwrite: true, + token: BLOB_TOKEN, + }); + console.log(`[${svc.id}] ✓ synced (${domain})`); + synced++; + } catch (err) { + console.error(`[${svc.id}] upload failed:`, err); + failed++; + } + } + } + + // Write manifest + const manifest = { transparent: transparentIds, lightBg: lightBgIds }; + if (DRY_RUN) { + console.log(`[dry-run] would upload logos/_manifest.json`); + } else { + await put(`logos/_manifest.json`, JSON.stringify(manifest, null, 2), { + access: "public", + contentType: "application/json", + addRandomSuffix: false, + allowOverwrite: true, + token: BLOB_TOKEN, + }); + } + + // Summary + console.log("\n--- sync-logos summary ---"); + console.log(` total: ${services.length}`); + console.log(` synced: ${synced}`); + console.log(` failed: ${failed}`); + console.log(` placeholders: ${placeholders}`); + + const summary = { + total: services.length, + synced, + failed, + placeholders, + transparent: transparentIds.length, + lightBg: lightBgIds.length, + dryRun: DRY_RUN, + }; + console.log(`\n${JSON.stringify(summary)}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/components/ServiceDiscovery.tsx b/src/components/ServiceDiscovery.tsx index 22362620..8bfcd67f 100644 --- a/src/components/ServiceDiscovery.tsx +++ b/src/components/ServiceDiscovery.tsx @@ -3,7 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { Category, Endpoint, Service } from "../data/registry"; -import { fetchServices } from "../data/registry"; +import { + fetchIconManifest, + fetchServices, + iconUrl as getIconUrlForService, + type IconManifest, + useIsDark, +} from "../data/registry"; import { PINNED_IDS } from "./ServicesPage"; const CATEGORY_LABELS: Record = { @@ -78,7 +84,7 @@ function getExamplePayload(ep: Endpoint): string { } function getIconUrl(service: Service): string { - return `/icons/${encodeURIComponent(service.id)}.svg`; + return getIconUrlForService(service.id); } // --------------------------------------------------------------------------- @@ -180,6 +186,7 @@ export function ServiceDiscovery({ const [services, setServices] = useState([]); const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); + const isDark = useIsDark(); const [selectedService, setSelectedService] = useState(null); const [showDropdown, setShowDropdown] = useState(false); const [transforms, setTransforms] = useState< @@ -191,6 +198,10 @@ export function ServiceDiscovery({ const inputRef = useRef(null); const brokenIcons = useRef(new Set()); const [, forceIconUpdate] = useState(0); + const [iconManifest, setIconManifest] = useState({ + transparent: new Set(), + lightBg: new Set(), + }); const [activeIndex, setActiveIndex] = useState(-1); const [isFocused, setIsFocused] = useState(false); const [dropdownTab, setDropdownTab] = useState< @@ -210,6 +221,9 @@ export function ServiceDiscovery({ setServices([...pinned, ...rest]); }) .catch(() => {}); + fetchIconManifest() + .then(setIconManifest) + .catch(() => {}); }, []); useEffect(() => { @@ -748,6 +762,11 @@ export function ServiceDiscovery({ src={iconUrl} alt="" className="discovery-card-icon-img" + style={ + isDark === iconManifest.lightBg.has(service.id) + ? { filter: "invert(1)" } + : undefined + } onError={() => { brokenIcons.current.add(service.id); forceIconUpdate((n) => n + 1); @@ -811,6 +830,8 @@ export function ServiceDiscovery({ createPortal( { setSelectedService(null); dismissMobileSearch(); @@ -833,9 +854,13 @@ export function ServiceDiscovery({ function ServiceDetailModal({ service, + iconManifest, + isDark, onClose, }: { service: Service; + iconManifest: IconManifest; + isDark: boolean; onClose: () => void; }) { const [selectedEndpoint, setSelectedEndpoint] = useState( @@ -987,7 +1012,9 @@ function ServiceDetailModal({ height: 44, borderRadius: 10, objectFit: "contain", - filter: "invert(var(--icon-invert, 0))", + ...(isDark === iconManifest.lightBg.has(service.id) + ? { filter: "invert(1)" } + : {}), }} /> ) : ( @@ -2251,7 +2278,6 @@ function DiscoveryStyles() { height: 28px; border-radius: 6px; object-fit: contain; - filter: invert(var(--icon-invert, 0)); } .discovery-card-icon-fallback { width: 28px; diff --git a/src/components/ServicesPage.tsx b/src/components/ServicesPage.tsx index 21e982b1..ade81490 100644 --- a/src/components/ServicesPage.tsx +++ b/src/components/ServicesPage.tsx @@ -3,7 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "vocs"; import type { Category, Endpoint, Service } from "../data/registry"; -import { fetchServices } from "../data/registry"; +import { + fetchIconManifest, + fetchServices, + type IconManifest, + iconUrl, + useIsDark, +} from "../data/registry"; import { ServiceDiscovery } from "./ServiceDiscovery"; export const CATEGORY_LABELS: Record = { @@ -792,7 +798,12 @@ function orderServices(services: Service[]): Service[] { // --------------------------------------------------------------------------- export function ServicesPage() { + const isDark = useIsDark(); const [services, setServices] = useState([]); + const [iconManifest, setIconManifest] = useState({ + transparent: new Set(), + lightBg: new Set(), + }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedCategory, setSelectedCategory] = useState( @@ -826,6 +837,9 @@ export function ServicesPage() { setError(err instanceof Error ? err.message : String(err)); setLoading(false); }); + fetchIconManifest() + .then(setIconManifest) + .catch(() => {}); }, []); useEffect(() => { @@ -1789,6 +1803,8 @@ export function ServicesPage() { toggleRow(s.id)} /> @@ -2966,8 +2982,17 @@ function FallbackIcon({ name }: { name: string }) { ); } -function ServiceIcon({ service: s }: { service: Service }) { +function ServiceIcon({ + service: s, + iconManifest, + isDark, +}: { + service: Service; + iconManifest: IconManifest; + isDark: boolean; +}) { const isFirstParty = s.integration !== "third-party"; + const needsInvert = isDark === iconManifest.lightBg.has(s.id); const [imgError, setImgError] = useState(false); return (
{s.id && !imgError ? ( setImgError(true)} /> @@ -3026,10 +3046,14 @@ function ServiceIcon({ service: s }: { service: Service }) { function ServiceRow({ service: s, + iconManifest, + isDark, expanded, onToggle, }: { service: Service; + iconManifest: IconManifest; + isDark: boolean; expanded: boolean; onToggle: () => void; }) { @@ -3074,7 +3098,11 @@ function ServiceRow({ paddingTop: "0.15rem", }} > - +
{ + const check = () => { + const scheme = document.documentElement.style.colorScheme; + setDark( + scheme === "dark" || + (!scheme && + window.matchMedia("(prefers-color-scheme: dark)").matches), + ); + }; + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["style"], + }); + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + mq.addEventListener("change", check); + return () => { + observer.disconnect(); + mq.removeEventListener("change", check); + }; + }, []); + return dark; +} + +// --------------------------------------------------------------------------- +// Icon manifest (transparency + background data from sync-logos) +// --------------------------------------------------------------------------- + +export interface IconManifest { + transparent: Set; + lightBg: Set; +} + +const EMPTY_MANIFEST: IconManifest = { + transparent: new Set(), + lightBg: new Set(), +}; + +let manifestCache: { data: IconManifest; ts: number } | null = null; +let manifestInflight: Promise | null = null; + +export async function fetchIconManifest(): Promise { + if (manifestCache && Date.now() - manifestCache.ts < CACHE_TTL_MS) { + return manifestCache.data; + } + + if (manifestInflight) return manifestInflight; + + manifestInflight = fetch("/api/icon-manifest") + .then((res) => (res.ok ? res.json() : { transparent: [], lightBg: [] })) + .then((json: { transparent: string[]; lightBg?: string[] }) => { + const manifest: IconManifest = { + transparent: new Set(json.transparent), + lightBg: new Set(json.lightBg ?? []), + }; + manifestCache = { data: manifest, ts: Date.now() }; + manifestInflight = null; + return manifest; + }) + .catch(() => { + manifestInflight = null; + return EMPTY_MANIFEST; + }); + + return manifestInflight; +} + +// --------------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------------- + export async function fetchServices(): Promise { if (cached && Date.now() - cached.ts < CACHE_TTL_MS) { return cached.data; diff --git a/src/pages/_api/api/icon-manifest.ts b/src/pages/_api/api/icon-manifest.ts new file mode 100644 index 00000000..196ea763 --- /dev/null +++ b/src/pages/_api/api/icon-manifest.ts @@ -0,0 +1,28 @@ +import { list } from "@vercel/blob"; + +const BLOB_TOKEN = process.env.BLOB_READ_WRITE_TOKEN; + +export async function GET() { + if (!BLOB_TOKEN) return Response.json({ transparent: [], lightBg: [] }); + try { + const { blobs } = await list({ + prefix: "logos/_manifest.json", + limit: 1, + token: BLOB_TOKEN, + }); + if (blobs.length > 0) { + const res = await fetch(blobs[0].url); + if (res.ok) { + const data = await res.json(); + return Response.json(data, { + headers: { + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate", + }, + }); + } + } + } catch (e) { + console.error("[icon-manifest] error:", e); + } + return Response.json({ transparent: [], lightBg: [] }); +} diff --git a/src/pages/_api/api/icon.ts b/src/pages/_api/api/icon.ts index 5bc72cf6..8216492b 100644 --- a/src/pages/_api/api/icon.ts +++ b/src/pages/_api/api/icon.ts @@ -1,12 +1,9 @@ -import { list, put } from "@vercel/blob"; -import discovery from "../../../../schemas/discovery.json"; +import { list } from "@vercel/blob"; -const LOGOLINK_KEY = process.env.BRANDDEV_LOGOLINK_KEY; const BLOB_TOKEN = process.env.BLOB_READ_WRITE_TOKEN; -const SVG_HEADERS = { - "Content-Type": "image/svg+xml", - "Cache-Control": "public, s-maxage=31536000, stale-while-revalidate", +const CACHE_HEADERS = { + "Cache-Control": "public, s-maxage=86400, stale-while-revalidate", }; const FALLBACK_HEADERS = { @@ -14,133 +11,79 @@ const FALLBACK_HEADERS = { "Cache-Control": "public, s-maxage=3600, stale-while-revalidate", }; -interface ServiceEntry { - id: string; - name: string; - url: string; - provider?: { name?: string; url?: string }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function domainFor(service: ServiceEntry): string | null { - const raw = service.provider?.url ?? service.url; - try { - return new URL(raw).hostname; - } catch { - return null; - } -} - -function styledSvg(logoDataUri: string): string { - return ``; +function letterSvg(id: string): string { + const letter = (id[0] ?? "?").toUpperCase(); + return `${letter}`; } -function letterSvg(name: string): string { - const letter = (name[0] ?? "?").toUpperCase(); - return `${letter}`; -} - -// --------------------------------------------------------------------------- -// Vercel Blob helpers -// --------------------------------------------------------------------------- - -async function blobGet(id: string): Promise { +async function blobGet( + id: string, +): Promise<{ body: ReadableStream; contentType: string } | null> { if (!BLOB_TOKEN) return null; try { - const { blobs } = await list({ - prefix: `icons/${id}.svg`, - limit: 1, - token: BLOB_TOKEN, - }); - if (blobs.length === 0) return null; - const res = await fetch(blobs[0].url); - return res.ok ? res.text() : null; + for (const ext of ["png", "svg"]) { + const { blobs } = await list({ + prefix: `logos/${id}.${ext}`, + limit: 1, + token: BLOB_TOKEN, + }); + if (blobs.length > 0) { + const res = await fetch(blobs[0].url); + if (res.ok && res.body) { + const ct = + res.headers.get("content-type") ?? + (ext === "svg" ? "image/svg+xml" : `image/${ext}`); + return { body: res.body, contentType: ct }; + } + } + } } catch (e) { console.error(`[icon] blob read error for ${id}:`, e); - return null; - } -} - -async function blobPut(id: string, svg: string): Promise { - if (!BLOB_TOKEN) return; - try { - await put(`icons/${id}.svg`, svg, { - access: "public", - contentType: "image/svg+xml", - addRandomSuffix: false, - token: BLOB_TOKEN, - }); - } catch (e) { - console.error(`[icon] blob write error for ${id}:`, e); } + return null; } -// --------------------------------------------------------------------------- -// brand.dev Logo Link -// --------------------------------------------------------------------------- - -async function fetchLogo(domain: string): Promise { - if (!LOGOLINK_KEY) return null; +async function staticFallback( + request: Request, + id: string, +): Promise { try { - const res = await fetch( - `https://logos.brand.dev/?publicClientId=${LOGOLINK_KEY}&domain=${domain}`, - ); - if (!res.ok) return null; - const ct = res.headers.get("content-type") ?? "image/png"; - const buf = await res.arrayBuffer(); - return `data:${ct};base64,${Buffer.from(buf).toString("base64")}`; - } catch (e) { - console.error(`[icon] brand.dev error for ${domain}:`, e); - return null; + const origin = new URL(request.url).origin; + const res = await fetch(`${origin}/icons/${id}.svg`); + if (res.ok) { + console.info(`[icon] serving static fallback for ${id}`); + return new Response(await res.text(), { headers: FALLBACK_HEADERS }); + } + } catch { + // static file not available } + return null; } -// --------------------------------------------------------------------------- -// Route handler -// --------------------------------------------------------------------------- - export async function GET(request: Request) { const id = new URL(request.url).searchParams.get("id"); if (!id) return new Response("Missing id parameter", { status: 400 }); - // 1. Vercel Blob cache - const cached = await blobGet(id); - if (cached) return new Response(cached, { headers: SVG_HEADERS }); - - // 2. Look up service → domain → brand.dev Logo Link - const services = (discovery as unknown as { services: ServiceEntry[] }) - .services; - const service = services.find((s) => s.id === id); - - if (service) { - const domain = domainFor(service); - if (domain) { - const dataUri = await fetchLogo(domain); - if (dataUri) { - const svg = styledSvg(dataUri); - await blobPut(id, svg); - return new Response(svg, { headers: SVG_HEADERS }); - } - } + // 1. Vercel Blob (primary source) + const blob = await blobGet(id); + if (blob) { + return new Response(blob.body, { + headers: { "Content-Type": blob.contentType, ...CACHE_HEADERS }, + }); } - // 3. Local dev fallback: try static /public/icons/ file + // 2. Static file fallback (public/icons/*.svg) if (!BLOB_TOKEN) { - try { - const origin = new URL(request.url).origin; - const res = await fetch(`${origin}/icons/${id}.svg`); - if (res.ok) - return new Response(await res.text(), { headers: SVG_HEADERS }); - } catch { - // static file not found, fall through to letter fallback - } + console.info( + `[icon] BLOB_READ_WRITE_TOKEN not set, using static fallback for ${id}`, + ); + } else { + console.warn(`[icon] blob miss for ${id}, trying static fallback`); } + const fallback = await staticFallback(request, id); + if (fallback) return fallback; - // 4. Letter fallback (short CDN cache so brand.dev is retried later) - return new Response(letterSvg(service?.name ?? id), { - headers: FALLBACK_HEADERS, - }); + // 3. Letter SVG (guaranteed — never 404) + console.warn(`[icon] no icon found for ${id}, generating letter fallback`); + return new Response(letterSvg(id), { headers: FALLBACK_HEADERS }); } diff --git a/src/pages/_root.css b/src/pages/_root.css index c3306259..f2d3cbbf 100644 --- a/src/pages/_root.css +++ b/src/pages/_root.css @@ -382,9 +382,6 @@ a[data-v-sidebar-item]:not([data-active="true"]):not(:hover) { rgba(255, 255, 255, 0.05) ); - /* Invert service icons in dark mode (0 = none, 1 = full invert) */ - --icon-invert: light-dark(0, 1); - /* Inline code background — kept for reference, actual override is on the element */ --background-color-inline-code: light-dark( var(--vocs-color-gray12),