site: ui/ux refresh, seo pass, find/lists indexes, 25 new finds
- newspaper-style masthead (stats | "Unique" | About/Blog) on every page with per-page italic subheader + last-updated date on listing pages; sitewide 42rem reading width - pinterest home (hero + masonry, "load more"); old list view at /index3/ - new pages: /about, /blog, /legal, /find/, /finds/, custom 404 - footer collection links now clickable; "founded" stat replaces "updated" in the masthead - centralised SEO component with per-page json-ld (Review, BlogPosting, Product, Article, CollectionPage, ItemList, BreadcrumbList, etc.); @astrojs/sitemap + @astrojs/rss; robots.txt; promoted finds canonical to their review - memoize getSiteStats() so the masthead/footer counts don't multiply per-page build cost - 25 new daily-finds captures
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Amazon Associates tracking IDs, keyed by Amazon hostname.
|
||||
// Add additional marketplaces here when applicable (each marketplace issues its own ID).
|
||||
const AMAZON_TAGS: Record<string, string> = {
|
||||
'amazon.com': 'unique.rzen.dev-20',
|
||||
};
|
||||
|
||||
// Hostnames that always belong to the US marketplace (short links resolve there).
|
||||
const AMAZON_US_SHORTLINKS = new Set(['amzn.to', 'a.co']);
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.toLowerCase().replace(/^www\./, '');
|
||||
}
|
||||
|
||||
function amazonHostFor(host: string): string | null {
|
||||
const h = normalizeHost(host);
|
||||
if (AMAZON_US_SHORTLINKS.has(h)) return 'amazon.com';
|
||||
if (h === 'smile.amazon.com') return 'amazon.com';
|
||||
if (h === 'amazon.com' || h.endsWith('.amazon.com')) return 'amazon.com';
|
||||
// Other regional marketplaces (amazon.co.uk, amazon.de, etc.). Only mark as
|
||||
// affiliate when we have a tag configured for that marketplace.
|
||||
const match = h.match(/^(?:[\w-]+\.)?(amazon\.(?:[a-z.]+))$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function isAmazonUrl(url: string): boolean {
|
||||
try {
|
||||
return amazonHostFor(new URL(url).hostname) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExternalLink {
|
||||
href: string;
|
||||
isAffiliate: boolean;
|
||||
network?: 'amazon';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the link to render externally. If the URL points at an Amazon
|
||||
* marketplace we have a tag for, append the tag and flag it as affiliate.
|
||||
* Otherwise return the URL unchanged.
|
||||
*/
|
||||
export function resolveExternalLink(rawUrl: string): ExternalLink {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
const marketplace = amazonHostFor(u.hostname);
|
||||
if (!marketplace) return { href: rawUrl, isAffiliate: false };
|
||||
const tag = AMAZON_TAGS[marketplace];
|
||||
if (!tag) return { href: rawUrl, isAffiliate: false, network: 'amazon' };
|
||||
u.searchParams.set('tag', tag);
|
||||
return { href: u.toString(), isAffiliate: true, network: 'amazon' };
|
||||
} catch {
|
||||
return { href: rawUrl, isAffiliate: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const AFFILIATE_REL = 'sponsored noopener noreferrer';
|
||||
export const AFFILIATE_DISCLOSURE =
|
||||
'Affiliate link — we may earn a commission from qualifying Amazon purchases at no extra cost to you.';
|
||||
@@ -50,3 +50,40 @@ export async function getAllEntries(): Promise<Entry[]> {
|
||||
|
||||
return [...r, ...p, ...f].sort((a, b) => b.date.valueOf() - a.date.valueOf());
|
||||
}
|
||||
|
||||
export type SiteStats = {
|
||||
total: number;
|
||||
lastUpdated: Date | null;
|
||||
reviews: number;
|
||||
finds: number;
|
||||
posts: number;
|
||||
lists: number;
|
||||
};
|
||||
|
||||
let statsCache: Promise<SiteStats> | null = null;
|
||||
|
||||
export function getSiteStats(): Promise<SiteStats> {
|
||||
if (!statsCache) statsCache = computeSiteStats();
|
||||
return statsCache;
|
||||
}
|
||||
|
||||
async function computeSiteStats(): Promise<SiteStats> {
|
||||
const entries = await getAllEntries();
|
||||
const findItems = await getCollection('find');
|
||||
const reviews = entries.filter((e) => e.type === 'review').length;
|
||||
const finds = findItems.length;
|
||||
const posts = entries.filter((e) => e.type === 'post').length;
|
||||
const lists = entries.filter((e) => e.type === 'finds').length;
|
||||
const allDates = [
|
||||
...entries.map((e) => e.date),
|
||||
...findItems.map((f) => f.data.date),
|
||||
].sort((a, b) => b.valueOf() - a.valueOf());
|
||||
return {
|
||||
total: reviews + finds + posts + lists,
|
||||
lastUpdated: allDates[0] ?? null,
|
||||
reviews,
|
||||
finds,
|
||||
posts,
|
||||
lists,
|
||||
};
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { heroes } from './hero';
|
||||
import type { OgImage } from '../components/SEO.astro';
|
||||
|
||||
export type OgFallback = 'reviews' | 'finds' | 'posts' | 'site';
|
||||
|
||||
const SITE_URL = 'https://unique.rzen.dev';
|
||||
|
||||
const FALLBACK_PATHS: Record<OgFallback, string> = {
|
||||
reviews: '/og/reviews.png',
|
||||
finds: '/og/finds.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';
|
||||
}
|
||||
Reference in New Issue
Block a user