diff --git a/CHANGELOG.md b/CHANGELOG.md index 8060df9..76585c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## May 2026 +- Comprehensive SEO pass: new `` component centralising every head-level tag (canonical, Open Graph, Twitter Card, theme-color, RSS alternate, JSON-LD); page-aware structured data — `Review` + `BreadcrumbList` on review pages, `BlogPosting` on posts, `Product` + `BreadcrumbList` on finds, `Article` + `ItemList` on finds superposts, `CollectionPage` + `ItemList` on category/tag/sources/blog pages, `WebSite` + `Organization` + `ItemList` on the home page, `AboutPage` on /about +- Add `@astrojs/sitemap` (auto sitemap-index.xml excluding `/grid/`, `/index3/`, `/404/`) and `@astrojs/rss` (feed at `/rss.xml` covering reviews + posts + finds superposts); add `public/robots.txt` pointing at the sitemap +- Add custom 404 page with recent reviews + nav fallback (noindex) +- Wire promoted finds to canonical to their review (via `promotedTo`); other finds self-canonical +- Noindex `/grid/` and `/index3/` (kept linked but absent from sitemap and search) +- Per-page descriptions + OG image alt text for every page; count-bearing titles and descriptions on category/tag pages (`{category} ({n})`); hero image upgraded with real alt text, `fetchpriority="high"`, `loading="eager"`, `decoding="sync"`; static OG fallback tiers in `/public/og/` (default/reviews/finds/posts — PNGs to be supplied) +- Set `trailingSlash: 'always'` and `build.format: 'directory'` explicitly to keep canonical URLs stable +- Memoize `getSiteStats()` so per-page SEO additions don't multiply build cost +- Site-wide UI/UX refresh: newspaper-style masthead on every page (entry-count + last-updated on the left, large serif "Unique" wordmark centered between thin rules, About · Blog nav on the right), per-page italic subheader with last-updated date on listing pages, and an SEO-friendly footer with Browse / Collections / About link columns +- Home page swapped to a Pinterest-style hero + masonry grid; the plain chronological list view moves to `/index3/` +- New `/about` stub and `/blog` index (Astro page over the `posts` collection with an empty-state when no posts exist) +- Single 76rem page width across the entire site (replaces the 42rem default + `wide` prop split); article prose is constrained to a 42rem reading measure inside the wider layout so reviews/finds stay readable +- New `getSiteStats()` helper in `src/lib/entries.ts` powers the masthead counts and footer Collections column live +- Amazon affiliate handling: outbound links to `amazon.com`, `amzn.to`, `a.co`, and `smile.amazon.com` get the configured tracking ID appended at render time, are marked with `rel="sponsored noopener noreferrer"`, and display a clearly-signposted "Affiliate" badge plus a one-line FTC-style disclosure on review and find pages. Tracking IDs live in `src/lib/affiliate.ts` keyed by marketplace; non-US Amazon hostnames are recognized but left untagged until a marketplace-specific ID is added - Restructure into a four-collection content pipeline: `find` (captured items, hidden from indexes), `finds` (editorial superposts that reference find slugs in a Pinterest-style grid), `reviews` (existing — gains optional `source` and `fromFind` fields for graduations), and `posts` (existing, generic essays) - New routes `/find//` (direct URL only — finds don't appear on home, tag, or category pages) and `/finds//` (superpost detail with intro + grid) - New components: `FindCard`, `FindsGrid`, `Backlinks`, `SourceBadge` diff --git a/README.md b/README.md index d20c608..ed9aefa 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,15 @@ Built with [Astro](https://astro.build) + MDX. - **Sources catalog** at `sources.json` with per-source fetch strategy (`webfetch` / `curl` / `skip`) and the `/sources` page rendering a 3-column card stream with a "Recent finds" sub-list per card - **Strict find visibility**: find items are reachable only via the superposts that reference them, the `/sources` cross-reference, or direct URL — they don't crowd the home feed, tag pages, or category pages - **Backlinks** computed at build time: each find page lists every superpost that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find -- **Unified chronological feed** on the home page across reviews + finds (superposts) + posts +- **Newspaper-style masthead** on every page: live entry-count + last-updated stats on the left, large serif "Unique" wordmark centered between thin rules, About/Blog nav on the right; per-page italic subheader ("A small log of delightful things.") with last-updated date on listing pages +- **Pinterest-style home page**: hero card for the latest review plus a 3-column masonry grid of remaining reviews with a "Load more" reveal; old plain-list home preserved at `/index3/` +- **Unified chronological feed** at `/index3/` across reviews + finds (superposts) + posts +- **SEO-friendly site footer** with Browse / Collections (live counts) / About link columns and a centered legal line; consistent 76rem page width across the entire site (article prose constrained to a comfortable reading measure inside the wider layout) - Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*) - Auto-generated `/categories//` and `/tags//` listing pages — finds excluded by design +- **Amazon affiliate handling** in `src/lib/affiliate.ts`: outbound links to Amazon (`amazon.com`, `amzn.to`, `a.co`, `smile.amazon.com`) get the configured tracking ID appended automatically; the link gets `rel="sponsored"`, an "Affiliate" badge appears next to it, and a one-line disclosure is shown beneath. Add additional marketplace IDs (e.g. `amazon.co.uk`) to the `AMAZON_TAGS` map when needed +- **SEO surface**: single `` component owning canonical, Open Graph, Twitter Card, and JSON-LD; per-page structured data (`Review`, `BlogPosting`, `Product`, `Article`, `CollectionPage`, `ItemList`, `BreadcrumbList`, `WebSite`, `Organization`); auto-generated `sitemap-index.xml` (excludes `/grid/` and `/index3/`); RSS feed at `/rss.xml`; `robots.txt`; static OG image fallbacks under `/public/og/`; promoted finds canonical to their review +- Custom 404 page with recent-reviews and navigation fallback - Light/dark mode via `prefers-color-scheme` - No JavaScript shipped to the client - Static build, deployable anywhere @@ -93,7 +99,12 @@ src/ layouts/ BaseLayout.astro pages/ - index.astro # unified feed + index.astro # Pinterest-style home (hero + masonry) + index3.astro # plain chronological list view + about.astro # About page + blog.astro # /posts index (essays) + grid.astro # full grid of every review + sources.astro # source catalog reviews/[...slug].astro posts/[...slug].astro categories/[category].astro diff --git a/astro.config.mjs b/astro.config.mjs index 3ce7a24..c970c29 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,7 +1,22 @@ import { defineConfig } from 'astro/config'; import mdx from '@astrojs/mdx'; +import sitemap from '@astrojs/sitemap'; + +const EXCLUDED_PATHS = new Set([ + 'https://unique.rzen.dev/grid/', + 'https://unique.rzen.dev/index3/', + 'https://unique.rzen.dev/404/', +]); export default defineConfig({ site: 'https://unique.rzen.dev', - integrations: [mdx()], + trailingSlash: 'always', + build: { format: 'directory' }, + integrations: [ + mdx(), + sitemap({ + filter: (page) => !EXCLUDED_PATHS.has(page), + changefreq: 'weekly', + }), + ], }); diff --git a/candidate-sources.md b/candidate-sources.md index f399f1d..aa0b399 100644 --- a/candidate-sources.md +++ b/candidate-sources.md @@ -5,3 +5,34 @@ Newly nominated sources awaiting review before promotion to `sources.json`. Each ## 2026-05-03 - **Robin Good's "Best Curated Newsletters"** — https://robingood.substack.com/ — surfaced via WebSearch. Annual list of curated newsletters; less a daily-find source than a yearly source-of-sources to scan once and harvest. Worth checking the most recent edition for newsletters in our brief that we don't yet track. + +### Gift-focused round (Amazon-leaning) + +Goal of this round: expand `gifts` coverage with sources that lean Amazon-purchasable, and probe whether Amazon's own surfaces (Idea Lists, Influencer Storefronts) are usable as feeds. Note up front: Amazon does not run a unified consumer discovery blog — the closest native equivalents are user-curated Idea Lists (`amazon.com/ideas/...`) and Influencer Storefronts (`amazon.com/shop/{handle}`), neither of which has a central "browse new" feed. Discovery there is per-curator. All US-locale; `src/lib/affiliate.ts` only tags `amazon.com` today. + +**Editorial curators (highest signal-to-noise, vetted gift voice):** + +- **Wirecutter — Gifts** — https://www.nytimes.com/wirecutter/gifts/ — surfaced via WebSearch. NYT-owned product testing publication; the gifts hub is a perennial source of considered, taste-driven recommendations, and most picks have an Amazon purchase path. Likely needs `curl` (NYT blocks WebFetch) or `skip` if paywalled — vet fetchability first. +- **The Strategist — Gifts** (New York Magazine) — https://nymag.com/strategist/tags/gifts/ — surfaced via WebSearch. Long-running gift-recommendations vertical with strong editorial voice; many picks Amazon-purchasable. Same publisher anti-bot risks as NYT may apply. +- **Apartment Therapy — The Bazaar** — https://www.apartmenttherapy.com/the-bazaar — surfaced via WebSearch. Home-and-living shopping vertical; lots of Amazon picks framed by design taste. Likely WebFetch-friendly. +- **Girls' Night In — "The Cute List"** (Alisha Ramos) — https://girlsnightin.substack.com/ — surfaced via WebSearch. Taste-driven curation of "delightful discoveries" (style and culture); gift-adjacent with regular Amazon links. Substack feed available, so the existing `curl` pattern used for Recomendo should work directly. +- **Wirecutter — Deals** — https://www.nytimes.com/wirecutter/deals/ — sister hub to Gifts; surfaces Amazon-discounted versions of already-vetted picks, useful as a "gift on sale" signal. Same fetch caveat as Wirecutter — Gifts. + +**Reddit communities (cheap to add — existing curl + JSON path supports them via `top/.json?t=week&limit=25`):** + +- **r/AmazonFinds** — https://www.reddit.com/r/AmazonFinds/ — community-tagged Amazon discoveries. Mixed quality (some TikTok-style slop), but the top-of-week filter should surface the genuinely upvoted finds. Vet for delightfulness vs. cheap-gadget noise before promoting. +- **r/Gifts** — https://www.reddit.com/r/Gifts/ — request-and-recommendation community; many resolved threads point to Amazon. Useful for "what to give X" patterns rather than discrete products. Vet noise level — requests outnumber recommendations. +- **r/HelpMeFind** — https://www.reddit.com/r/HelpMeFind/ — people hunting for specific items; resolutions often land at Amazon. Lower volume of pure gift content but high signal when a thread closes with an answer. +- **r/INEEEEDIT** — https://www.reddit.com/r/INEEEEDIT/ — community sharing surprising/delightful product finds, very gift-shaped. Strong overlap with the "delightful and distinctive" brief; many items are Amazon-buyable. + +**Amazon-native surfaces (experimental — fetchability uncertain, per-curator quality varies):** + +- **Amazon Influencer Storefront — Alix Earle** — https://www.amazon.com/shop/alixearle — surfaced via WebSearch (AmraAndElma roundup). Trial example of a storefront with unusual categories like "Gift Guides for a Partner's Parents" and "Amazon Luxury." Vet for taste fit — fashion/beauty leaning may be off-brief. Storefronts are SPA-heavy; both `webfetch` and `curl` may fail. +- **Amazon Influencer Storefront — Julie Capozziello** — https://www.amazon.com/shop/juliecapozziello — surfaced via WebSearch. 64+ curated Idea Lists across lifestyle, gifts, books, snacks. Trial example of breadth. Same fetch and taste caveats as above. +- **Amazon Gift Finder hub** — https://www.amazon.com/gift-finder — Amazon's own editorial gift surface. Expect heavy algorithmic + sponsored content; included as a baseline to evaluate whether Amazon-native editorial is worth tracking at all. Likely fails the brief on its own; useful as a control. +- **Amazon Idea Lists search** — https://www.amazon.com/gift-ideas-list/s?k=gift+ideas+list — there is no native "browse all Idea Lists" page, so a search-results URL is the closest thing to a discovery feed. Probably noisy; flagged for evaluation rather than confident inclusion. + +**Sources-of-sources (one-shot scan, not daily fetches — same pattern as Robin Good above):** + +- **AmraAndElma — "25 Top Influencers with Amazon Storefronts" (2026)** — https://www.amraandelma.com/top-influencers-with-amazon-storefronts/ — yearly roundup; scan once to harvest more storefronts that match the brief, then drop. +- **The Influence Agency — "Top Amazon Storefront Creators"** — https://theinfluenceagency.com/blog/amazon-storefront-top-creators-2025 — second curator-of-curators reference; harvest names, then drop. diff --git a/daily-finds.log.md b/daily-finds.log.md index 9d09c31..7d982af 100644 --- a/daily-finds.log.md +++ b/daily-finds.log.md @@ -2,6 +2,52 @@ Running record of every candidate surfaced by `/daily-finds`. The chosen pick of the day is prepended with `[chosen]`. Source attribution per candidate lets us track which sources produce winners over time. +## 2026-05-03 — capture run (Amazon-focused sweep) + +Sources scanned: 47 total — 8 hits, ~21 empty, 9 blocked/broken (WebFetch 403/500/empty body, redirect-only), 9 skipped (per sources.json `fetch: skip`). +Productive sources: Tools and Toys (4), Six Colors (1), Recomendo (7), Cool Tools (1), One Thing Well (3), Web Curios (3), Kottke.org (1), r/macapps (4). +Unproductive but live: MacStories (Apple Arcade week), Daring Fireball, Hacker News — Show HN, Product Hunt, Werd.com (listicles only), Carryology (insights essays, no products), Kikkerland (Juicy Jingles already captured; rest didn't qualify), Things Magazine (link roundups, no products), A Continuous Lean (newsletter/collab announcements), Put This On (eBay roundups), Standard & Strange Journal (Field Guides), Outlier (have URLs, skipped this run), Roadtrippers Magazine, The Outbound Collective, AFAR, Wallpaper* Travel, Boutique Homes, Uses This, Open Culture, Messy Nessy Chic, r/BuyItForLife (anecdotes, no clean product picks), Cool Material, StyleForum, Kevin Kelly thetechnium. +Blocked or broken this run: Atlas Obscura (WebFetch 403; curl returned content but anchors were nav-only), This Is Why I'm Broke (WebFetch 404 on /new), Unusual Hotels of the World (empty body via curl), Snow Peak (nav-only via WebFetch), Uncommon Goods (empty fetch), MoMA Design Store (curl 1.2MB but anchor extraction got nav only). + +### Captured finds + +- felo-ergonic-screwdriver-set — Felo "Ergonic" Screwdriver Set — source: Tools and Toys +- umoven-pop-up-bifold-wallet — umoven Pop-Up Bifold Wallet — source: Tools and Toys +- ugreen-uno-magsafe-power-bank — UGREEN Uno MagSafe Power Bank — source: Tools and Toys +- espro-p7-french-press — Espro P7 French Press — source: Tools and Toys +- anker-nano-travel-adapter — Anker Nano Universal Travel Adapter — source: Six Colors +- aootek-solar-fence-lights — Aootek Motion-Sensing Solar Fence Lights — source: Recomendo +- birdfy-smart-bird-feeder — Birdfy AI Smart Bird Feeder — source: Recomendo +- casabrews-cm5418-espresso-machine — Casabrews CM5418 Espresso Machine — source: Recomendo +- skull-bluffing-card-game — Skull (Card Game) — source: Recomendo +- toms-keys-spare-fob — Tom's Keys — source: Recomendo +- stillgram — Stillgram — source: Recomendo +- magnifiedsand — Magnified Sand — source: Recomendo +- embassy-of-the-free-mind — Embassy of the Free Mind — source: Cool Tools +- frogfind — FrogFind — source: One Thing Well +- cilicon-ephemeral-vms — Cilicon — source: One Thing Well +- companion-apps-for-apple-music — Companion Apps for Apple Music — source: One Thing Well +- roll-disposable-camera — Roll: Disposable Camera — source: Web Curios +- damnlines — Damnlines — source: Web Curios +- magic-postcards — Penumbra Magic Postcards — source: Web Curios +- unruly-play-archive — Unruly Play — source: Kottke.org +- dockpops — DockPops — source: r/macapps +- fluttertime — FlutterTime — source: r/macapps +- refine-grammarly-alternative — Refine — source: r/macapps +- notepad-plus-plus-mac — Notepad++ for Mac — source: r/macapps + +New source nominees: 0 this run — the standout finds linked to single products / single-developer GitHubs (Stillgram, Refine, Cilicon, Mario A. Guzman's iTunes recreations) rather than to publications worth tracking. Existing candidate-sources.md still has the gift-focused Amazon round queued for vetting. + +### Source maintenance for next pruning pass + +- **MoMA Design Store**: curl returns 1.2MB of HTML but the product grid is rendered via JS — only nav anchors are extractable. Would need a headless browser, or the storefront's collection JSON endpoint if Shopify exposes one. Same root cause as Snow Peak's "nav only" result. +- **Atlas Obscura**: curl returns 503KB body and HTTP 200, but the anchor parser only extracted the global nav (Places/Foods/Stories) — likely because the Recently Added cards are rendered client-side too. Try fetching `/places/recent` via curl with the Safari UA, or the regional sitemap. +- **This Is Why I'm Broke**: WebFetch on `/new` 404'd and the homepage HTML uses `{{entry.title}}` Mustache placeholders — the product feed is loaded client-side. Skip-mark for now or find their JSON endpoint. +- **Unusual Hotels of the World**: curl returned 4KB and `Uhotw` — the site appears to be a thin SPA shell. Probably needs `fetch: skip` or a different fetchUrl. +- **Uncommon Goods**: WebFetch returned empty for both `/new-arrivals` URLs tried — needs a fetchUrl override. +- **Snow Peak**: nav-only response on `/collections/new-arrivals` — likely Shopify SPA. Look for a JSON product feed URL. +- **r/BuyItForLife**: top-of-week is heavy on personal-anecdote photo posts ("My N64 still works after 30 years") rather than product picks. The signal-to-noise ratio is lower than the macapps subreddit; may want to filter on text posts only or pin a sub-filter URL. + ## 2026-05-03 — theme: single-virtue tools Sources scanned: 46 total — 23 hits, 5 empty, 17 blocked (Cloudflare 403 / 404 / timeout / dead domain), 1 skipped (paywalled). diff --git a/package-lock.json b/package-lock.json index cc5b958..a2a2c5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@astrojs/check": "^0.9.4", "@astrojs/mdx": "^4.3.14", + "@astrojs/rss": "^4.0.12", + "@astrojs/sitemap": "^3.6.0", "astro": "^5.1.1", "typescript": "^5.7.2" } @@ -153,6 +155,46 @@ "node": "18.20.8 || ^20.3.0 || >=22.0.0" } }, + "node_modules/@astrojs/rss": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.18.tgz", + "integrity": "sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.7", + "piccolore": "^0.1.3", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/rss/node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.2.tgz", + "integrity": "sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "stream-replace-string": "^2.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/sitemap/node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@astrojs/telemetry": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", @@ -1228,6 +1270,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -1717,6 +1771,24 @@ "@types/unist": "*" } }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1969,6 +2041,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2963,6 +3041,42 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4817,6 +4931,21 @@ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "license": "MIT" }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -5423,6 +5552,25 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, "node_modules/smol-toml": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", @@ -5463,6 +5611,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -5509,6 +5663,18 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -5688,6 +5854,12 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index e9e9663..227a26c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dependencies": { "@astrojs/check": "^0.9.4", "@astrojs/mdx": "^4.3.14", + "@astrojs/rss": "^4.0.12", + "@astrojs/sitemap": "^3.6.0", "astro": "^5.1.1", "typescript": "^5.7.2" } diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..0528a3b --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://unique.rzen.dev/sitemap-index.xml diff --git a/src/components/SEO.astro b/src/components/SEO.astro new file mode 100644 index 0000000..6a8af92 --- /dev/null +++ b/src/components/SEO.astro @@ -0,0 +1,101 @@ +--- +export interface OgImage { + url: string; + width: number; + height: number; + alt: string; + type?: string; +} + +interface Props { + title: string; + description: string; + canonicalPath?: string; + canonicalOverride?: string; + ogType?: 'website' | 'article'; + ogImage?: OgImage; + noindex?: boolean; + publishedTime?: Date; + modifiedTime?: Date; + articleTags?: string[]; + jsonLd?: Record | Record[]; +} + +const { + title, + description, + canonicalPath, + canonicalOverride, + ogType = 'website', + ogImage, + noindex = false, + publishedTime, + modifiedTime, + articleTags = [], + jsonLd, +} = Astro.props; + +const SITE_NAME = 'Unique'; +const titleTag = title === SITE_NAME ? SITE_NAME : `${title} — ${SITE_NAME}`; + +const canonicalUrl = + canonicalOverride ?? + new URL(canonicalPath ?? Astro.url.pathname, Astro.site).href; + +const jsonLdBlocks = jsonLd + ? Array.isArray(jsonLd) + ? jsonLd + : [jsonLd] + : []; +--- + + + +{titleTag} + + +{noindex && } + + + + + + + + + + + + +{ogImage && ( + <> + + + + + {ogImage.type && } + +)} + +{ogType === 'article' && publishedTime && ( + +)} +{ogType === 'article' && modifiedTime && ( + +)} +{ogType === 'article' && + articleTags.map((t) => )} + + + + +{ogImage && ( + <> + + + +)} + +{jsonLdBlocks.map((block) => ( + diff --git a/src/pages/index3.astro b/src/pages/index3.astro new file mode 100644 index 0000000..5f462fc --- /dev/null +++ b/src/pages/index3.astro @@ -0,0 +1,19 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import EntryList from '../components/EntryList.astro'; +import { getAllEntries } from '../lib/entries'; + +const allEntries = await getAllEntries(); +const entries = allEntries.slice(0, 12); +const lastUpdated = allEntries[0]?.date; +--- + + + + diff --git a/src/pages/legal.astro b/src/pages/legal.astro new file mode 100644 index 0000000..9f6d2fd --- /dev/null +++ b/src/pages/legal.astro @@ -0,0 +1,77 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import { buildOgImage, buildBreadcrumbs } from '../lib/seo'; + +const ogImage = buildOgImage(undefined, 'site', 'Unique legal page'); +const breadcrumbs = buildBreadcrumbs([ + { name: 'Home', url: '/' }, + { name: 'Legal', url: '/legal/' }, +]); +--- + + +
+

Privacy

+

+ Unique doesn't run analytics, ad pixels, or third-party trackers. The + site sets no cookies of its own. Your visit produces a normal web-server + access log entry (IP, user-agent, referrer, requested path) at our + hosting provider, kept only for the rolling window needed to investigate + abuse and outages, and never sold or shared. +

+

+ Outbound links go to third-party sites that have their own privacy + practices; we have no control over those. +

+ +

Affiliate disclosure

+

+ Some outbound links — most often to Amazon — are affiliate links. If you + buy something after clicking one, we may earn a small commission at no + additional cost to you. Affiliate links are marked with an + Affiliate badge and a one-line disclosure on each page + where they appear. Affiliate revenue does not influence what we cover or + how we cover it; nothing on this site is sponsored or paid placement. +

+ +

Trademarks & copyright

+

+ All product names, logos, screenshots, and brand marks belong to their + respective owners and appear here under fair use for editorial review + and commentary. If you're a rights-holder and would like an image + removed or attribution corrected, please get in touch. +

+

+ Original writing on Unique is © {new Date().getFullYear()} Unique, all + rights reserved. +

+ +

Use of the site

+

+ Content is provided as-is for informational purposes; we make no + warranty that any product covered here will suit your particular + situation. Use your own judgement before buying or installing anything. +

+ +

Contact

+

+ Questions, corrections, or takedown requests: reach out via the address + on the About page. +

+
+ + +
diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index 35ed407..4f7bc35 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -1,6 +1,12 @@ --- import { getCollection, render } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; +import { + buildOgImage, + buildBreadcrumbs, + PERSON_AUTHOR, + absoluteUrl, +} from '../../lib/seo'; export async function getStaticPaths() { const posts = await getCollection('posts'); @@ -18,9 +24,40 @@ const fmt = new Intl.DateTimeFormat('en-US', { month: 'long', day: 'numeric', }); + +const description = post.data.description ?? `Notes from Unique: ${post.data.title}.`; +const ogImage = buildOgImage(undefined, 'posts', `${post.data.title} cover image`); + +const blogPosting: Record = { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + '@id': absoluteUrl(`/posts/${post.id}/#post`), + headline: post.data.title, + description, + url: absoluteUrl(`/posts/${post.id}/`), + datePublished: post.data.date.toISOString(), + author: PERSON_AUTHOR, + image: ogImage.url, + inLanguage: 'en-US', + mainEntityOfPage: absoluteUrl(`/posts/${post.id}/`), +}; + +const breadcrumbs = buildBreadcrumbs([ + { name: 'Home', url: '/' }, + { name: 'Blog', url: '/blog/' }, + { name: post.data.title, url: `/posts/${post.id}/` }, +]); --- - +

{post.data.title}

diff --git a/src/pages/reviews/[...slug].astro b/src/pages/reviews/[...slug].astro index f2a103f..29a8589 100644 --- a/src/pages/reviews/[...slug].astro +++ b/src/pages/reviews/[...slug].astro @@ -2,6 +2,18 @@ import { getCollection, render } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import SourceBadge from '../../components/SourceBadge.astro'; +import { + resolveExternalLink, + AFFILIATE_REL, + AFFILIATE_DISCLOSURE, +} from '../../lib/affiliate'; +import { + buildOgImage, + buildBreadcrumbs, + reviewedItemType, + PERSON_AUTHOR, + absoluteUrl, +} from '../../lib/seo'; export async function getStaticPaths() { const reviews = await getCollection('reviews'); @@ -24,14 +36,53 @@ const linkLabel = review.data.link && (review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, '')); +const external = review.data.link ? resolveExternalLink(review.data.link) : undefined; + const fromFindItem = review.data.fromFind ? (await getCollection('find')).find((f) => f.id === review.data.fromFind) : undefined; + +const description = + review.data.description ?? review.data.subtitle ?? `A review of ${review.data.name}.`; + +const ogImage = buildOgImage(review.id, 'reviews', `${review.data.name} hero image`); + +const itemReviewed: Record = { + '@type': reviewedItemType(review.data.category), + name: review.data.name, +}; +if (review.data.link) itemReviewed.url = review.data.link; +if (ogImage) itemReviewed.image = ogImage.url; + +const reviewSchema: Record = { + '@context': 'https://schema.org', + '@type': 'Review', + '@id': absoluteUrl(`/reviews/${review.id}/#review`), + url: absoluteUrl(`/reviews/${review.id}/`), + name: review.data.name, + headline: review.data.subtitle || review.data.name, + description, + datePublished: review.data.date.toISOString(), + author: PERSON_AUTHOR, + inLanguage: 'en-US', + itemReviewed, +}; + +const breadcrumbs = buildBreadcrumbs([ + { name: 'Home', url: '/' }, + { name: review.data.category, url: `/categories/${review.data.category}/` }, + { name: review.data.name, url: `/reviews/${review.id}/` }, +]); ---
{review.data.source && ( @@ -50,16 +101,24 @@ const fromFindItem = review.data.fromFind {review.data.category} { - review.data.link && ( + external && ( <> · - + {linkLabel} ↗ + {external.isAffiliate && Affiliate} ) }

+ {external?.isAffiliate && ( +

{AFFILIATE_DISCLOSURE}

+ )}
{fromFindItem && ( @@ -120,6 +179,26 @@ const fromFindItem = review.data.fromFind .meta .dot { margin: 0 0.4rem; } .meta .category { color: var(--muted); } .meta .category:hover { color: var(--accent); } + .aff-badge { + display: inline-block; + margin-left: 0.45rem; + padding: 0.12rem 0.45rem; + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + background: var(--rule); + border-radius: 999px; + vertical-align: middle; + } + .aff-disclosure { + margin: 0.6rem 0 0; + font-size: 0.78rem; + color: var(--muted); + font-style: italic; + } .body :global(p) { margin: 0 0 1rem; } .body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; } .body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; } diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js new file mode 100644 index 0000000..ea7105b --- /dev/null +++ b/src/pages/rss.xml.js @@ -0,0 +1,43 @@ +import rss from '@astrojs/rss'; +import { getCollection } from 'astro:content'; + +export async function GET(context) { + const [reviews, posts, finds] = await Promise.all([ + getCollection('reviews'), + getCollection('posts'), + getCollection('finds'), + ]); + + const items = [ + ...reviews.map((r) => ({ + title: r.data.name, + description: r.data.description ?? r.data.subtitle ?? '', + pubDate: r.data.date, + link: `/reviews/${r.id}/`, + categories: [r.data.category, ...r.data.tags], + })), + ...posts.map((p) => ({ + title: p.data.title, + description: p.data.description ?? '', + pubDate: p.data.date, + link: `/posts/${p.id}/`, + categories: [p.data.category, ...p.data.tags], + })), + ...finds.map((f) => ({ + title: f.data.title, + description: f.data.description ?? f.data.blurb ?? '', + pubDate: f.data.date, + link: `/finds/${f.id}/`, + categories: [f.data.category, ...f.data.tags], + })), + ].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf()); + + return rss({ + title: 'Unique', + description: + 'A small log of delightful, unique things — products, apps, phenomena, oddities.', + site: context.site, + items, + customData: 'en-us', + }); +} diff --git a/src/pages/sources.astro b/src/pages/sources.astro index ad36711..2c17670 100644 --- a/src/pages/sources.astro +++ b/src/pages/sources.astro @@ -2,6 +2,12 @@ import BaseLayout from '../layouts/BaseLayout.astro'; import sourcesData from '../../sources.json'; import { getAllFinds } from '../lib/find-items'; +import { + buildOgImage, + buildBreadcrumbs, + buildItemList, + absoluteUrl, +} from '../lib/seo'; type Source = { name: string; @@ -40,14 +46,43 @@ for (const f of allFinds) { arr.push(f); findsBySource.set(f.data.source, arr); } + +const lastUpdated = allFinds.sort( + (a, b) => b.data.date.valueOf() - a.data.date.valueOf() +)[0]?.data.date; + +const description = `The ${sources.length} feeds, shops, and forums Unique draws daily picks from.`; +const ogImage = buildOgImage(undefined, 'site', 'Unique sources catalog'); + +const itemList = buildItemList( + sources.map((s) => ({ url: s.url, name: s.name })), + 'Sources used by Unique' +); + +const collectionPage: Record = { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + '@id': absoluteUrl('/sources/#collection'), + name: 'Sources — Unique', + description, + url: absoluteUrl('/sources/'), + inLanguage: 'en-US', +}; + +const breadcrumbs = buildBreadcrumbs([ + { name: 'Home', url: '/' }, + { name: 'Sources', url: '/sources/' }, +]); --- - -
-

Sources

-

Where the daily picks come from. {sources.length} feeds, shops, and forums.

-
- +
    { sources.map((source) => { @@ -93,12 +128,6 @@ for (const f of allFinds) {

    ← back