~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
176 lines · 5.7 KB Raw History
// Search-engine metadata: descriptions and schema.org structured data for the
// pages worth indexing. OGMeta renders what these return.

import type { GalleryView, GrainActorDefsProfileViewDetailed } from "$hatk/client";

type Label = { val: string; neg?: boolean };

/** Clip to a search snippet's length on a word boundary. */
export function snippet(text: string | null | undefined, max = 160): string | undefined {
  const flat = text?.replace(/\s+/g, " ").trim();
  if (!flat) return undefined;
  if (flat.length <= max) return flat;
  const cut = flat.slice(0, max - 1);
  const space = cut.lastIndexOf(" ");
  return `${space > max * 0.6 ? cut.slice(0, space) : cut}…`;
}

/**
 * Whether labels keep a page out of search. Any active label does: a
 * moderation label means the content is hidden, blurred, or behind a warning
 * in the app, and a search result shows none of that. `!no-unauthenticated`
 * is the author asking not to be shown to logged-out viewers, which a crawler
 * is.
 */
export function labelsBlockIndexing(labels: Label[] | undefined): boolean {
  return !!labels?.some((l) => !l.neg);
}

/** Structured data wants absolute URLs; image views are relative in dev. */
function absolute(url: string, origin: string): string {
  return url.startsWith("http") ? url : `${origin}${url}`;
}

function personLd(
  p: {
    did: string;
    handle?: string;
    displayName?: string;
    avatar?: string;
    description?: string;
  },
  origin: string,
  path: string,
) {
  return {
    "@type": "Person",
    name: p.displayName || p.handle || p.did,
    ...(p.handle ? { alternateName: `@${p.handle}` } : {}),
    identifier: p.did,
    url: `${origin}${path}`,
    ...(p.avatar ? { image: absolute(p.avatar, origin) } : {}),
    ...(p.description ? { description: p.description } : {}),
  };
}

/** https://developers.google.com/search/docs/appearance/structured-data/profile-page */
export function profileJsonLd(p: GrainActorDefsProfileViewDetailed, origin: string, path: string) {
  return {
    "@context": "https://schema.org",
    "@type": "ProfilePage",
    url: `${origin}${path}`,
    ...(p.createdAt ? { dateCreated: p.createdAt } : {}),
    mainEntity: {
      ...personLd(p, origin, path),
      interactionStatistic: [
        {
          "@type": "InteractionCounter",
          interactionType: "https://schema.org/FollowAction",
          userInteractionCount: p.followersCount ?? 0,
        },
      ],
      agentInteractionStatistic: {
        "@type": "InteractionCounter",
        interactionType: "https://schema.org/WriteAction",
        userInteractionCount: p.galleryCount ?? 0,
      },
    },
  };
}

/**
 * A gallery as a social media posting, with each photo as an ImageObject that
 * carries its creator. Google reads the per-image creator and credit for image
 * search, and the posting type for the result itself.
 * https://developers.google.com/search/docs/appearance/structured-data/discussion-forum
 * https://developers.google.com/search/docs/appearance/structured-data/image-license-metadata
 */
export function galleryJsonLd(g: GalleryView, origin: string, path: string, authorPath: string) {
  const author = personLd(g.creator, origin, authorPath);
  const credit = g.creator.handle ? `@${g.creator.handle}` : author.name;
  return {
    "@context": "https://schema.org",
    "@type": "SocialMediaPosting",
    url: `${origin}${path}`,
    headline: g.title,
    ...(g.description ? { text: g.description } : {}),
    ...(g.createdAt ? { datePublished: g.createdAt } : {}),
    ...(g.locationDisplay
      ? { contentLocation: { "@type": "Place", name: g.locationDisplay } }
      : {}),
    author,
    image: (g.items ?? []).map((photo) => ({
      "@type": "ImageObject",
      contentUrl: absolute(photo.fullsize, origin),
      thumbnailUrl: absolute(photo.thumb, origin),
      ...(photo.alt ? { caption: photo.alt } : {}),
      ...(photo.aspectRatio
        ? { width: photo.aspectRatio.width, height: photo.aspectRatio.height }
        : {}),
      creator: { "@type": "Person", name: author.name, url: author.url },
      creditText: credit,
      copyrightNotice: `© ${credit}`,
    })),
    interactionStatistic: [
      {
        "@type": "InteractionCounter",
        interactionType: "https://schema.org/LikeAction",
        userInteractionCount: g.favCount ?? 0,
      },
      {
        "@type": "InteractionCounter",
        interactionType: "https://schema.org/CommentAction",
        userInteractionCount: g.commentCount ?? 0,
      },
    ],
  };
}

/** The site itself, for the home page. */
export function siteJsonLd(origin: string) {
  return {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "WebSite",
        name: "Grain",
        alternateName: "grain.social",
        url: `${origin}/`,
      },
      {
        "@type": "Organization",
        name: "Grain",
        url: `${origin}/`,
        logo: `${origin}/icon-512.png`,
        sameAs: [
          "https://bsky.app/profile/grain.social",
          "https://apps.apple.com/app/id6747730230",
        ],
      },
    ],
  };
}

/**
 * at-tags: `<meta name="at:*">` naming the atproto records a page renders
 * (https://tangled.org/chrisshank.com/at-tags). `canonical` is the record the
 * page exists for, `alternate` records it shows besides, `author` the accounts
 * that wrote it. Every value is an AT URI, so an account is `at://<did>`.
 */
export type AtTags = {
  canonical?: string[];
  alternate?: string[];
  author?: string[];
};

export function atTags(
  canonical: string,
  author: string,
  alternate: (string | undefined)[] = [],
): AtTags {
  return {
    canonical: [canonical],
    alternate: alternate.filter((uri): uri is string => !!uri?.startsWith("at://")),
    author: [`at://${author}`],
  };
}