Ctrl K
ComunidadGuías…

app/og/route.tsx

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

Archivo original ↗Preguntar a KiBot
import { ImageResponse } from "next/og";
import { NextResponse, type NextRequest } from "next/server";
import { getClientIp } from "@/lib/server/request-ip";
import { rateLimitRefusal } from "@/lib/server/session-rate-limit";
import { MINDDY_LOGO_PATH, MINDDY_LOGO_VIEWBOX } from "@/lib/brand";
import { locales, defaultLocale, type Locale } from "@/i18n/config";
import { loadMessages } from "@/i18n/messages";
import { PUBLIC_ROUTES, routeByKey, type PublicRouteKey } from "@/lib/public-routes";
import { metaExcerpt } from "@/lib/seo";
import { SITE_NAME } from "@/lib/site";

/**
 * Public site sharing thumbnail (MIN-88, redesign MIN-512) — what you see when a
 * minddy link is stuck in Slack, X or an email client. A parameterized route
 * renders one image per public page and per language: `?route=<key>&locale=<lang>`
 * reads the texts in `messages/<lang>.json`, so the sticker never drifts from the
 * page copy. `lib/seo.ts` is the only caller.
 *
 * The design borrows the landing's palette instead of inventing one: the pastel
 * surfaces of `components/marketing/card-tones.ts` (sage, lavender, peach, sky)
 * laid over a warm cream, with the brand drawn in ink like the app's `--primary`.
 * Deliberately flat — satori has no blur, and at sticker size the pastel blocks
 * carry more than any detail would.
 */

export const contentType = "image/png";

const SIZE = { width: 1200, height: 630 };

const ROUTE_KEYS = new Set<string>(PUBLIC_ROUTES.map((route) => route.key));

function parseParams(request: NextRequest): { key: PublicRouteKey; locale: Locale } {
  const params = request.nextUrl.searchParams;
  const rawKey = params.get("route") ?? "";
  const rawLocale = params.get("locale") ?? "";
  return {
    // Public parameters, therefore arbitrary: we fall back to the landing in
    // English rather than rendering an error image in a link preview.
    key: (ROUTE_KEYS.has(rawKey) ? rawKey : "home") as PublicRouteKey,
    locale: ((locales as readonly string[]).includes(rawLocale)
      ? rawLocale
      : defaultLocale) as Locale,
  };
}

/**
 * How many thumbnails a single IP address can RENDER per minute.
 *
 * The satori rendering of a 1200x630 PNG is, by far, the most expensive
 * calculation this app offers without authentication. The bound is high because
 * legitimate scrapers (Slack, X, an email client) type in bursts on the
 * eighteen canonical addresses — but it exists, which was not always the case.
 */
const OG_RATE_LIMIT = { limit: 60, windowMs: 60_000 };

/** Warm ink used for every text and the logo, in the spirit of `--primary`. */
const INK = "#26332c";
const MUTED = "#5e6b61";

/**
 * The title is the only variable-height element: metaTitles range from
 * "Legal notice" to a 71-character French sentence. Three sizes, chosen by
 * length, keep the block between one and three lines inside the card for every
 * known catalog entry — a fixed size either wastes the short titles or
 * overflows on the long ones.
 */
function titleFontSize(length: number): number {
  if (length > 56) return 58;
  if (length > 38) return 68;
  return 80;
}

/**
 * Some metaTitles open with the brand — "minddy: open-source project
 * management…" — which the wordmark in the header already says. Drop that
 * leading "<name>:" (French spacing included) so the headline starts on its
 * substance; titles where the brand is part of the sentence ("minddy Cloud
 * pricing…", "minddy pour macOS") keep it.
 */
function headlineOf(title: string): string {
  const brand = SITE_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  return title.replace(new RegExp(`^${brand}\\s*:\\s*`, "i"), "");
}

export async function GET(request: NextRequest) {
  const { key, locale } = parseParams(request);
  const route = routeByKey(key);

  // The CACHE KEY, first (MIN-348). The content depends only on two
  // parameters, but the CDN indexes on the entire URL: `?route=home&x=1`,
  // `&x=2`, `&x=3`… are as many new entries, therefore as many renderings. A
  // non-canonical address is therefore returned to the canonical — 308, without
  // rendering the image — and there are only about twenty URLs left to render
  // for the whole site. The limiter below only keeps these.
  const canonical = `route=${key}&locale=${locale}`;
  if (request.nextUrl.search.replace(/^\?/, "") !== canonical) {
    const target = new URL(request.nextUrl);
    target.search = canonical;
    return NextResponse.redirect(target, {
      status: 308,
      headers: { "Cache-Control": "public, max-age=3600, s-maxage=86400" },
    });
  }

  const refused = rateLimitRefusal(`ip:${getClientIp(request)}`, "og", OG_RATE_LIMIT);
  if (refused) return refused;

  const messages = await loadMessages(locale) as Record<string, Record<string, string>>;
  const namespace = messages[route.namespace] ?? {};
  const headline = headlineOf(namespace.metaTitle ?? SITE_NAME);
  const description = metaExcerpt(namespace.metaDescription ?? "");

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          overflow: "hidden",
          position: "relative",
          background:
            "linear-gradient(120deg, #f7f3e8 0%, #e4edda 45%, #dde9f4 100%)",
          padding: 80,
        }}
      >
        {/* Pastel shapes cropped by the canvas, echoing the landing's cards
            (components/marketing/card-tones.ts) in deliberately fuller tints
            so the hues survive thumbnail size. First in DOM order: the content
            below paints on top of them. */}
        <div
          style={{
            position: "absolute",
            top: -210,
            right: -170,
            width: 560,
            height: 560,
            borderRadius: 9999,
            background: "#eae0f6",
            display: "flex",
          }}
        />
        <div
          style={{
            position: "absolute",
            bottom: -210,
            left: -160,
            width: 520,
            height: 520,
            borderRadius: 9999,
            background: "#f5e2c6",
            display: "flex",
          }}
        />
        <div
          style={{
            position: "absolute",
            bottom: -170,
            right: -130,
            width: 400,
            height: 400,
            borderRadius: 9999,
            background: "#f2d8e2",
            display: "flex",
          }}
        />

        <div
          style={{
            display: "flex",
            flexDirection: "column",
            justifyContent: "space-between",
            width: "100%",
            height: "100%",
          }}
        >
          <div style={{ display: "flex", alignItems: "center", gap: 18 }}>
            <svg width={62} height={62} viewBox={MINDDY_LOGO_VIEWBOX} fill={INK}>
              <path fillRule="evenodd" clipRule="evenodd" d={MINDDY_LOGO_PATH} />
            </svg>
            <span style={{ fontSize: 46, color: INK, letterSpacing: -1.5 }}>
              {SITE_NAME}
            </span>
          </div>

          <div
            style={{
              display: "flex",
              flexDirection: "column",
              flexGrow: 1,
              justifyContent: "center",
              gap: 26,
            }}
          >
            <span
              style={{
                fontSize: titleFontSize(headline.length),
                color: INK,
                letterSpacing: -2.2,
                lineHeight: 1.12,
                maxWidth: 1010,
              }}
            >
              {headline}
            </span>
            {description ? (
              <span
                style={{
                  fontSize: 27,
                  color: MUTED,
                  maxWidth: 940,
                  lineHeight: 1.5,
                }}
              >
                {description}
              </span>
            ) : null}
          </div>
        </div>
      </div>
    ),
    {
      ...SIZE,
      headers: {
        // The content only depends on the two URL parameters: once rendered,
        // the thumbnail can remain for a long time in the CDN and with scrapers.
        "Cache-Control": "public, max-age=3600, s-maxage=86400, immutable",
      },
    },
  );
}