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.
127 lines
2.9 KiB
TypeScript
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';
|
|
}
|