Skip to content
SlashClone

Show "works with" app logos and filter by app

@slashclone/integration-logos

Add a "works with" feature to a catalog or marketplace: each item shows logos of the apps it integrates with, and people can browse and filter search by app. Tags are detected from strong signals like env vars, hosts, and MCP servers before mentions, owners can override them, and logos come from simple-icons with a lettered fallback, an SVG safety check, and a dry-run retag job.

SkillOtherv0.1.0Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Reference detector: which catalog apps does one item work with? Node built-ins only.
  3. 3// Usage: node scripts/detect-integrations.mjs --catalog examples/catalog.json
  4. 4// --item examples/item.json [--out out/integrations.json]
  5. 5// Prints and writes ids and the signal that found each one. Never writes item content.
  6. 6import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
  7. 7import { dirname } from "node:path";
  8. 8import { parseArgs } from "node:util";
  9. 9
  10. 10const MAX_TOTAL = 12; // tags in all
  11. 11const MAX_MENTIONS = 6; // tags found only by mentions
  12. 12const FILE_MENTIONS_NEEDED = 2; // mentions in files needed to count
  13. 13const SKIP_FILES =
  14. 14 /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|[^/]+\.lock)$|\.(png|jpe?g|gif|webp|ico|pdf|zip|woff2?|ttf)$/i;
  15. 15const LINK = /\b(?:https?:\/\/|www\.)\S+/gi;
  16. 17const { values } = parseArgs({
  17. 18 options: {
  18. 19 catalog: { type: "string" },
  19. 20 item: { type: "string" },
  20. 21 out: { type: "string", default: "out/integrations.json" },
  21. 22 },
  22. 23});
  23. 24if (!values.catalog || !values.item) {
  24. 25 console.error("Need --catalog and --item JSON files.");
  25. 26 process.exit(2);
  26. 28const catalog = JSON.parse(readFileSync(values.catalog, "utf8")).integrations;
  27. 29const item = JSON.parse(readFileSync(values.item, "utf8"));
  28. 30const list = (v) => (Array.isArray(v) ? v.map(String) : []);
  29. 32const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  30. 33function patternsFor(entry) {
  31. 34 const words = (s, flags) => new RegExp(`(?<![\\w.-])${escape(s)}(?![\\w-])`, flags);
  32. 35 const aliases = list(entry.aliases).map((a) => words(a, "gi"));
  33. 36 if (entry.rule === "aliases") return aliases; // common words: never the bare name
  34. 37 if (entry.rule === "case") return [words(entry.name, "g"), ...aliases]; // capitals required
  35. 38 return [words(entry.name, "gi"), ...aliases];
  36. 40const count = (text, res) => res.reduce((n, re) => n + (text.match(re)?.length ?? 0), 0);
  37. 42const found = new Map(); // id -> via, in the order found
  38. 43const add = (id, via) => {
  39. 44 if (!found.has(id) && found.size < MAX_TOTAL) found.set(id, via);
  40. 45};
  41. 46const host = (h) => h.toLowerCase().replace(/^\*\./, "");
  42. 48// 1. Strong signals.
  43. 49const known = new Set(catalog.map((e) => e.id));
  44. 50for (const id of list(item.declared)) if (known.has(id)) add(id, "declared");
  45. 51for (const e of catalog) {
  46. 52 if (list(e.mcp).some((m) => list(item.mcp).includes(m))) add(e.id, "mcp");
  47. 53 const hit = list(item.hosts).some((h) =>
  48. 54 list(e.domains).some((d) => host(h) === d || host(h).endsWith(`.${d}`)),
  49. 55 );
  50. 56 if (hit) add(e.id, "network");
  51. 57 if (list(e.envPrefixes).some((p) => list(item.env).some((n) => n.startsWith(p))))
  52. 58 add(e.id, "env");
  53. 59 if (list(e.workflowNodes).some((n) => list(item.workflowNodes).includes(n)))
  54. 60 add(e.id, "workflow");
  55. 63// 2. Mentions: one in the name or description, or enough across files. Links ignored.
  56. 64const strip = (t) => String(t ?? "").replace(LINK, " ");
  57. 65const head = strip(`${item.name ?? ""}\n${item.description ?? ""}`);
  58. 66const body = (Array.isArray(item.files) ? item.files : [])
  59. 67 .filter((f) => !SKIP_FILES.test(String(f.path)))
  60. 68 .map((f) => strip(f.text))
  61. 69 .join("\n");
  62. 70let mentions = 0;
  63. 71for (const e of catalog) {
  64. 72 if (found.has(e.id) || mentions >= MAX_MENTIONS) continue;
  65. 73 const res = patternsFor(e);
  66. 74 if (res.length === 0) continue;
  67. 75 if (count(head, res) >= 1 || count(body, res) >= FILE_MENTIONS_NEEDED) {
  68. 76 const before = found.size;
  69. 77 add(e.id, "mention");
  70. 78 if (found.size > before) mentions++;
  71. 79 }
  72. 82const result = { ids: [...found.keys()], via: Object.fromEntries(found) };
  73. 83mkdirSync(dirname(values.out), { recursive: true });
  74. 84writeFileSync(values.out, `${JSON.stringify(result, null, 2)}\n`);
  75. 85console.log(JSON.stringify(result));

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.