0
2
// 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}`],
};
}