Ctrl K
ComunidadGuías…

src/lib/social.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * The places a person can be reached, and how to read a link to one.
 *
 * Named `social`, not `links`: `src/lib/links.ts` is the project's own
 * addresses (the manual), and two unrelated things cannot share a filename
 * however well each name fits on its own.
 *
 * Pure — no database, no React — because both the contact form and anything
 * rendering a contact needs it, and a client component may not pull Prisma
 * into the browser bundle. Same reason `src/lib/audit-groups.ts` exists.
 *
 * A stored link is whatever the person typed. We normalise on the way in, but
 * every reader here tolerates the untidy forms already in the database:
 * "linkedin.com/in/will", "@will", a bare domain, a full URL.
 */

export type PlatformKey = "linkedin" | "twitter" | "instagram" | "github" | "website" | "other";

export type Platform = {
  key: PlatformKey;
  label: string;
  /** Hostnames that mean this platform. Empty for the catch-alls. */
  hosts: string[];
  /** Where a bare handle gets expanded to. Null when a handle is meaningless. */
  handleBase: string | null;
  placeholder: string;
};

/**
 * X is listed under `twitter` because that is the column's name and renaming a
 * column to follow a rebrand is a migration that buys nothing. The label is
 * what people see, and that says X.
 */
export const PLATFORMS: Platform[] = [
  {
    key: "linkedin",
    label: "LinkedIn",
    hosts: ["linkedin.com", "lnkd.in"],
    handleBase: "https://linkedin.com/in/",
    placeholder: "linkedin.com/in/…",
  },
  {
    key: "twitter",
    label: "X",
    hosts: ["x.com", "twitter.com"],
    handleBase: "https://x.com/",
    placeholder: "@handle or x.com/…",
  },
  {
    key: "instagram",
    label: "Instagram",
    hosts: ["instagram.com"],
    handleBase: "https://instagram.com/",
    placeholder: "@handle or instagram.com/…",
  },
  {
    key: "github",
    label: "GitHub",
    hosts: ["github.com"],
    handleBase: "https://github.com/",
    placeholder: "@handle or github.com/…",
  },
  {
    key: "website",
    label: "Website",
    hosts: [],
    handleBase: null,
    placeholder: "their site or blog",
  },
  {
    key: "other",
    label: "Other",
    hosts: [],
    handleBase: null,
    placeholder: "Bluesky, Mastodon, a Substack…",
  },
];

export const PLATFORM_LABEL: Record<PlatformKey, string> = Object.fromEntries(
  PLATFORMS.map((platform) => [platform.key, platform.label]),
) as Record<PlatformKey, string>;

/** The named columns on Contact, in the order a person reads them. */
export const NAMED_PLATFORMS: Exclude<PlatformKey, "other">[] = [
  "linkedin",
  "twitter",
  "instagram",
  "github",
  "website",
];

function hostOf(value: string): string | null {
  const trimmed = value.trim();
  if (!trimmed || trimmed.startsWith("@")) return null;
  try {
    const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
    // A bare handle parses as a hostname with no dot in it — "will" becomes
    // https://will. That is not a website, so it is not a link either.
    if (!url.hostname.includes(".")) return null;
    return url.hostname.replace(/^www\./, "").toLowerCase();
  } catch {
    return null;
  }
}

/** Does this hostname belong to one of these? Subdomains count. */
function hostMatches(host: string, hosts: string[]): boolean {
  return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`));
}

/**
 * Which platform a pasted link belongs to, or null when it is not a link at
 * all. Anything that IS a link but matches no known host is a website.
 */
export function detectPlatform(value: string): PlatformKey | null {
  const host = hostOf(value);
  if (!host) return null;
  const match = PLATFORMS.find((platform) => hostMatches(host, platform.hosts));
  return match?.key ?? "website";
}

/**
 * The mark to print beside a link.
 *
 * Wider than PLATFORMS on purpose. A contact has columns for five platforms,
 * so those are what the picker offers — but a link filed under "other" is
 * still somebody's YouTube or Twitch, and a chain-link icon on every one of
 * them says nothing. This list is read only for the icon: it never changes
 * where a link is stored or which slot it lands in.
 *
 * Only platforms whose mark is genuinely recognisable are listed. A Bluesky
 * link staying a chain link is better than one wearing a bird that isn't
 * theirs.
 */
export type BrandKey =
  | PlatformKey
  | "youtube"
  | "facebook"
  | "twitch"
  | "dribbble"
  | "figma"
  | "slack";

const BRAND_HOSTS: { key: BrandKey; hosts: string[] }[] = [
  ...PLATFORMS.filter((platform) => platform.hosts.length > 0).map((platform) => ({
    key: platform.key as BrandKey,
    hosts: platform.hosts,
  })),
  { key: "youtube", hosts: ["youtube.com", "youtu.be"] },
  { key: "facebook", hosts: ["facebook.com", "fb.com"] },
  { key: "twitch", hosts: ["twitch.tv"] },
  { key: "dribbble", hosts: ["dribbble.com"] },
  { key: "figma", hosts: ["figma.com"] },
  { key: "slack", hosts: ["slack.com"] },
];

export const BRAND_LABEL: Record<BrandKey, string> = {
  ...PLATFORM_LABEL,
  youtube: "YouTube",
  facebook: "Facebook",
  twitch: "Twitch",
  dribbble: "Dribbble",
  figma: "Figma",
  slack: "Slack",
};

/**
 * Which mark a stored link wears. Never null: something that is not a link at
 * all still has to render as something, and that something is a chain link.
 */
export function brandOf(value: string): BrandKey {
  const host = hostOf(value);
  if (!host) return "other";
  const match = BRAND_HOSTS.find((brand) => hostMatches(host, brand.hosts));
  return match?.key ?? "website";
}

/**
 * The platform to SHOW for a stored link.
 *
 * What the host says, mostly — a YouTube URL sitting in the website column is
 * still a YouTube link. The column it is filed under only gets a say when the
 * host said nothing useful, which is what an untidy stored value ("@will",
 * typed before there was a picker) reduces to.
 */
export function brandFor(value: string, filedAs?: PlatformKey): BrandKey {
  const detected = brandOf(value);
  if (detected !== "other" && detected !== "website") return detected;
  if (filedAs && filedAs !== "other" && filedAs !== "website") return filedAs;
  // A real URL nothing recognises is a website, not a chain link, whatever
  // slot it happens to be filed in.
  return detected;
}

/**
 * What to store. A full URL stays as it is; a bare handle is expanded against
 * the platform it was filed under, which is the whole reason the picker offers
 * a platform rather than guessing from "@will".
 */
export function normaliseLink(value: string, platform: PlatformKey): string {
  const trimmed = value.trim();
  if (!trimmed) return "";
  if (hostOf(trimmed)) return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
  const base = PLATFORMS.find((candidate) => candidate.key === platform)?.handleBase;
  if (!base) return trimmed;
  return `${base}${trimmed.replace(/^@/, "")}`;
}

/** An openable address for a stored value. Empty when there is nothing to open. */
export function linkHref(value: string): string {
  const trimmed = value.trim();
  if (!trimmed) return "";
  if (/^https?:\/\//i.test(trimmed)) return trimmed;
  return hostOf(trimmed) ? `https://${trimmed}` : "";
}

/**
 * The short form to print. A profile URL reads as its handle, because
 * "@bywilliaml" is what someone recognises and
 * "https://x.com/bywilliaml?utm=…" is not.
 */
export function linkLabel(value: string): string {
  const trimmed = value.trim();
  if (!trimmed) return "";
  if (trimmed.startsWith("@")) return trimmed;

  const host = hostOf(trimmed);
  if (!host) return trimmed;

  try {
    const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
    const path = url.pathname.replace(/\/+$/, "");
    const platform = detectPlatform(trimmed);
    if (platform && platform !== "website" && platform !== "other") {
      // "/in/will" and "/will" both end in the handle worth showing.
      const handle = path.split("/").filter(Boolean).pop();
      if (handle) return `@${handle}`;
    }
    return path ? `${host}${path}` : host;
  } catch {
    return trimmed;
  }
}