Files
unique/src/lib/seo.ts
T
rzen 049c6c5377 site: rename "finds" superpost collection to "bundles"
Disambiguates the one-letter `find` (item) vs `finds` (roundup) split that
kept causing confusion. Audience-facing now: `/bundles/<slug>/`, "Bundle"
labels in home/footer/search.

Internals: `getCollection('finds')` → `('bundles')`, `Entry.type 'finds'`
→ `'bundle'`, `OgFallback 'finds'` → `'bundles'`, `findListMosaic` →
`bundleMosaic`, `FindsPost` → `Bundle`, `findsReferencing` →
`bundlesReferencing`. Build script + generated mosaic dir follow
(`scripts/build-bundle-mosaics.mjs`, `src/generated/bundle-mosaics/`).

Skills `build-finds` → `build-bundle`, `cluster-finds` →
`cluster-bundles`. The `find` collection (individual items) is unchanged.

No URL redirects: low external-link surface area.
2026-05-06 20:43:22 -04:00

127 lines
2.9 KiB
TypeScript

import { heroes } from './hero';
import type { OgImage } from '../components/SEO.astro';
export type OgFallback = 'reviews' | 'bundles' | 'posts' | 'site';
const SITE_URL = 'https://unique.rzen.dev';
const FALLBACK_PATHS: Record<OgFallback, string> = {
reviews: '/og/reviews.png',
bundles: '/og/bundles.png',
posts: '/og/posts.png',
site: '/og/default.png',
};
const FALLBACK_DIMS = { width: 1200, height: 630, type: 'image/png' };
export function buildOgImage(
heroId: string | undefined,
fallback: OgFallback,
alt: string
): OgImage {
const hero = heroId ? heroes[heroId] : undefined;
if (hero?.kind === 'image') {
const fmt = hero.src.format;
const mime = fmt
? `image/${fmt === 'jpg' ? 'jpeg' : fmt}`
: undefined;
return {
url: new URL(hero.src.src, SITE_URL).href,
width: hero.src.width,
height: hero.src.height,
alt,
type: mime,
};
}
return {
url: new URL(FALLBACK_PATHS[fallback], SITE_URL).href,
width: FALLBACK_DIMS.width,
height: FALLBACK_DIMS.height,
alt,
type: FALLBACK_DIMS.type,
};
}
export function absoluteUrl(path: string): string {
return new URL(path, SITE_URL).href;
}
export type Crumb = { name: string; url: string };
export function buildBreadcrumbs(crumbs: Crumb[]): Record<string, unknown> {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: crumbs.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
item: absoluteUrl(c.url),
})),
};
}
export type ListItem = { url: string; name: string };
export function buildItemList(
items: ListItem[],
name?: string
): Record<string, unknown> {
return {
'@context': 'https://schema.org',
'@type': 'ItemList',
...(name ? { name } : {}),
numberOfItems: items.length,
itemListElement: items.map((it, i) => ({
'@type': 'ListItem',
position: i + 1,
name: it.name,
url: absoluteUrl(it.url),
})),
};
}
export const PERSON_AUTHOR = {
'@type': 'Person',
name: 'rzen',
url: `${SITE_URL}/about/`,
};
export const ORGANIZATION = {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${SITE_URL}/#org`,
name: 'Unique',
url: SITE_URL,
logo: `${SITE_URL}/favicon.svg`,
};
export const WEBSITE = {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${SITE_URL}/#site`,
name: 'Unique',
url: SITE_URL,
description:
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
publisher: { '@id': `${SITE_URL}/#org` },
inLanguage: 'en-US',
};
const SOFTWARE_CATEGORIES = new Set([
'apps',
'app',
'software',
'macos-apps',
'macos',
'ios-apps',
'ios',
'tools',
]);
export function reviewedItemType(category: string): 'SoftwareApplication' | 'Product' {
return SOFTWARE_CATEGORIES.has(category.toLowerCase())
? 'SoftwareApplication'
: 'Product';
}