From fb463ec8f3132fb8a4d564b59227a98f43f151d8 Mon Sep 17 00:00:00 2001 From: Christian Jarling Date: Fri, 27 Feb 2026 21:39:49 +0100 Subject: [PATCH] Add websites I like --- .gitignore | 4 + app/about/page.mdx | 3 + app/websites-i-like/page.tsx | 98 ++++++++ content/websites/0xdf4.yaml | 2 + content/websites/100-rabbits.yaml | 2 + content/websites/alexey-guzey.yaml | 2 + content/websites/alistair-shepherd.yaml | 2 + content/websites/brian-lovin.yaml | 2 + content/websites/brittany-chiang.yaml | 2 + content/websites/cold-takes.yaml | 2 + content/websites/daniel-miessler.yaml | 2 + content/websites/daniel-sun.yaml | 2 + content/websites/dimitrios-lytras.yaml | 2 + content/websites/drew-devault.yaml | 2 + content/websites/emma-goto.yaml | 2 + content/websites/gwern.yaml | 2 + content/websites/hardeeps-iphone-notes.yaml | 2 + content/websites/henry-codes.yaml | 2 + content/websites/ineza-bonte.yaml | 2 + content/websites/jahir-fiquitiva.yaml | 2 + content/websites/jordi-enric.yaml | 2 + content/websites/josh-w-comeau.yaml | 2 + content/websites/maggie-appleton.yaml | 2 + content/websites/maxime-heckel.yaml | 2 + content/websites/nico-espeon.yaml | 2 + content/websites/not-a-number.yaml | 2 + content/websites/paul-scanlon.yaml | 2 + content/websites/stefan-judis.yaml | 2 + content/websites/timo-mamecke.yaml | 2 + data/cms.ts | 7 + data/db/schema.ts | 32 +++ data/website-images.repo.ts | 90 +++++++ data/websites.dto.ts | 18 ++ docker-compose.yml | 6 +- drizzle/0003_marvelous_captain_flint.sql | 12 + drizzle/meta/0003_snapshot.json | 235 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- keystatic.config.ts | 15 ++ package.json | 5 + pnpm-lock.yaml | 266 +++++++++++--------- scripts/fetch-og-images.ts | 244 ++++++++++++++++++ scripts/migrate-images-to-db.ts | 111 ++++++++ 42 files changed, 1087 insertions(+), 120 deletions(-) create mode 100644 app/websites-i-like/page.tsx create mode 100644 content/websites/0xdf4.yaml create mode 100644 content/websites/100-rabbits.yaml create mode 100644 content/websites/alexey-guzey.yaml create mode 100644 content/websites/alistair-shepherd.yaml create mode 100644 content/websites/brian-lovin.yaml create mode 100644 content/websites/brittany-chiang.yaml create mode 100644 content/websites/cold-takes.yaml create mode 100644 content/websites/daniel-miessler.yaml create mode 100644 content/websites/daniel-sun.yaml create mode 100644 content/websites/dimitrios-lytras.yaml create mode 100644 content/websites/drew-devault.yaml create mode 100644 content/websites/emma-goto.yaml create mode 100644 content/websites/gwern.yaml create mode 100644 content/websites/hardeeps-iphone-notes.yaml create mode 100644 content/websites/henry-codes.yaml create mode 100644 content/websites/ineza-bonte.yaml create mode 100644 content/websites/jahir-fiquitiva.yaml create mode 100644 content/websites/jordi-enric.yaml create mode 100644 content/websites/josh-w-comeau.yaml create mode 100644 content/websites/maggie-appleton.yaml create mode 100644 content/websites/maxime-heckel.yaml create mode 100644 content/websites/nico-espeon.yaml create mode 100644 content/websites/not-a-number.yaml create mode 100644 content/websites/paul-scanlon.yaml create mode 100644 content/websites/stefan-judis.yaml create mode 100644 content/websites/timo-mamecke.yaml create mode 100644 data/website-images.repo.ts create mode 100644 data/websites.dto.ts create mode 100644 drizzle/0003_marvelous_captain_flint.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 scripts/fetch-og-images.ts create mode 100644 scripts/migrate-images-to-db.ts diff --git a/.gitignore b/.gitignore index 7104833..eb257d9 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,8 @@ node_modules # Postgres postgres-data +website-next-postgres-data data/GeoLite2-Country.mmdb + +# Website OG images (stored in DB) +public/website-og-images/ diff --git a/app/about/page.mdx b/app/about/page.mdx index 8a5ba3e..b70a06d 100644 --- a/app/about/page.mdx +++ b/app/about/page.mdx @@ -1,4 +1,5 @@ import { formatDistanceToNowStrict } from 'date-fns' +import Link from 'next/link' export const birthDay = new Date('22 Jul 1991') export const yearsOld = formatDistanceToNowStrict(birthDay, { @@ -36,6 +37,8 @@ I've been writing on this website since 2017 (which is 7 years at the time of wr although I have changed the domain name once in 2022 when I got married and took on my wife's last name. +I also enjoy discovering other people's websites and exploring them. I maintain a list of Websites I like. + ## Colophon This site is built with Next.js and hosted on Vercel. Content is just diff --git a/app/websites-i-like/page.tsx b/app/websites-i-like/page.tsx new file mode 100644 index 0000000..0c63355 --- /dev/null +++ b/app/websites-i-like/page.tsx @@ -0,0 +1,98 @@ +import { getAllWebsites, getWebsiteImageMap } from 'data/websites.dto' +import { getLogger } from 'lib/logger' +import { ExternalLink } from 'lucide-react' +import { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Websites I Like - Chris Jarling', + description: 'A curated collection of websites I enjoy.', +} + +export default async function WebsitesILikePage() { + const websites = await getAllWebsites() + + let imageMap: Record = {} + try { + imageMap = await getWebsiteImageMap() + } catch (error) { + getLogger() + .withError(error as Error) + .error('Failed to load website images') + } + + const websitesWithImages = websites.map((website) => { + let hostname: string + try { + hostname = new URL(website.entry.url).hostname + } catch { + hostname = website.entry.url + } + return { + ...website, + image: imageMap[website.slug] ?? null, + hostname, + } + }) + + return ( + <> +
+

Websites I Like

+

+ A list of websites I like for one reason or another. Could be, because + I enjoy how they look or are built or what they write about. +

+
+ +
+ {websitesWithImages.map((website) => ( + +
+ {website.image?.type === 'og' ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + {website.entry.title} + ) : website.image?.type === 'favicon' ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {website.entry.title} +
+ ) : ( +
+ No preview +
+ )} +
+ +
+

+ {website.entry.title} +

+

+ + {website.hostname} +

+
+
+ ))} + + {websites.length === 0 && ( +

No websites yet.

+ )} +
+ + ) +} diff --git a/content/websites/0xdf4.yaml b/content/websites/0xdf4.yaml new file mode 100644 index 0000000..8cdf85e --- /dev/null +++ b/content/websites/0xdf4.yaml @@ -0,0 +1,2 @@ +title: '0xDF4' +url: 'https://0xdf4.com/' diff --git a/content/websites/100-rabbits.yaml b/content/websites/100-rabbits.yaml new file mode 100644 index 0000000..2d0db43 --- /dev/null +++ b/content/websites/100-rabbits.yaml @@ -0,0 +1,2 @@ +title: 100 Rabbits +url: 'https://100r.co/site/home.html' diff --git a/content/websites/alexey-guzey.yaml b/content/websites/alexey-guzey.yaml new file mode 100644 index 0000000..f749f71 --- /dev/null +++ b/content/websites/alexey-guzey.yaml @@ -0,0 +1,2 @@ +title: Alexey Guzey +url: 'https://guzey.com/vibes/' diff --git a/content/websites/alistair-shepherd.yaml b/content/websites/alistair-shepherd.yaml new file mode 100644 index 0000000..303f7b2 --- /dev/null +++ b/content/websites/alistair-shepherd.yaml @@ -0,0 +1,2 @@ +title: Alistair Shepherd +url: 'https://alistairshepherd.uk/' diff --git a/content/websites/brian-lovin.yaml b/content/websites/brian-lovin.yaml new file mode 100644 index 0000000..63d155d --- /dev/null +++ b/content/websites/brian-lovin.yaml @@ -0,0 +1,2 @@ +title: Brian Lovin +url: 'https://brianlovin.com/' diff --git a/content/websites/brittany-chiang.yaml b/content/websites/brittany-chiang.yaml new file mode 100644 index 0000000..0de98a6 --- /dev/null +++ b/content/websites/brittany-chiang.yaml @@ -0,0 +1,2 @@ +title: Brittany Chiang +url: 'https://brittanychiang.com/' diff --git a/content/websites/cold-takes.yaml b/content/websites/cold-takes.yaml new file mode 100644 index 0000000..21da74d --- /dev/null +++ b/content/websites/cold-takes.yaml @@ -0,0 +1,2 @@ +title: Cold Takes +url: 'https://www.cold-takes.com/' diff --git a/content/websites/daniel-miessler.yaml b/content/websites/daniel-miessler.yaml new file mode 100644 index 0000000..ccbc708 --- /dev/null +++ b/content/websites/daniel-miessler.yaml @@ -0,0 +1,2 @@ +title: Daniel Miessler +url: 'https://danielmiessler.com/' diff --git a/content/websites/daniel-sun.yaml b/content/websites/daniel-sun.yaml new file mode 100644 index 0000000..69b8fdf --- /dev/null +++ b/content/websites/daniel-sun.yaml @@ -0,0 +1,2 @@ +title: Daniel Sun +url: 'https://danielsun.space/' diff --git a/content/websites/dimitrios-lytras.yaml b/content/websites/dimitrios-lytras.yaml new file mode 100644 index 0000000..390e961 --- /dev/null +++ b/content/websites/dimitrios-lytras.yaml @@ -0,0 +1,2 @@ +title: Dimitrios Lytras +url: 'https://dnlytras.com/' diff --git a/content/websites/drew-devault.yaml b/content/websites/drew-devault.yaml new file mode 100644 index 0000000..fd7f410 --- /dev/null +++ b/content/websites/drew-devault.yaml @@ -0,0 +1,2 @@ +title: Drew DeVault +url: 'https://drewdevault.com/' diff --git a/content/websites/emma-goto.yaml b/content/websites/emma-goto.yaml new file mode 100644 index 0000000..f7e6073 --- /dev/null +++ b/content/websites/emma-goto.yaml @@ -0,0 +1,2 @@ +title: Emma Goto +url: 'https://www.emgoto.com/' diff --git a/content/websites/gwern.yaml b/content/websites/gwern.yaml new file mode 100644 index 0000000..b1a0d37 --- /dev/null +++ b/content/websites/gwern.yaml @@ -0,0 +1,2 @@ +title: Gwern +url: 'https://gwern.net/changelog' diff --git a/content/websites/hardeeps-iphone-notes.yaml b/content/websites/hardeeps-iphone-notes.yaml new file mode 100644 index 0000000..b026724 --- /dev/null +++ b/content/websites/hardeeps-iphone-notes.yaml @@ -0,0 +1,2 @@ +title: Hardeep's iPhone Notes +url: 'https://hardeeps-iphone-notes.super.site/' diff --git a/content/websites/henry-codes.yaml b/content/websites/henry-codes.yaml new file mode 100644 index 0000000..7e046f2 --- /dev/null +++ b/content/websites/henry-codes.yaml @@ -0,0 +1,2 @@ +title: Henry Codes +url: 'https://henry.codes/work' diff --git a/content/websites/ineza-bonte.yaml b/content/websites/ineza-bonte.yaml new file mode 100644 index 0000000..9f7b554 --- /dev/null +++ b/content/websites/ineza-bonte.yaml @@ -0,0 +1,2 @@ +title: Ineza Bonté +url: 'https://ineza.codes/' diff --git a/content/websites/jahir-fiquitiva.yaml b/content/websites/jahir-fiquitiva.yaml new file mode 100644 index 0000000..f748e86 --- /dev/null +++ b/content/websites/jahir-fiquitiva.yaml @@ -0,0 +1,2 @@ +title: Jahir Fiquitiva +url: 'https://jahir.dev/' diff --git a/content/websites/jordi-enric.yaml b/content/websites/jordi-enric.yaml new file mode 100644 index 0000000..999e737 --- /dev/null +++ b/content/websites/jordi-enric.yaml @@ -0,0 +1,2 @@ +title: Jordi Enric +url: 'https://www.jordienric.com/' diff --git a/content/websites/josh-w-comeau.yaml b/content/websites/josh-w-comeau.yaml new file mode 100644 index 0000000..8cd2321 --- /dev/null +++ b/content/websites/josh-w-comeau.yaml @@ -0,0 +1,2 @@ +title: Josh W Comeau +url: 'https://www.joshwcomeau.com/snippets/' diff --git a/content/websites/maggie-appleton.yaml b/content/websites/maggie-appleton.yaml new file mode 100644 index 0000000..71e887f --- /dev/null +++ b/content/websites/maggie-appleton.yaml @@ -0,0 +1,2 @@ +title: Maggie Appleton +url: 'https://maggieappleton.com/' diff --git a/content/websites/maxime-heckel.yaml b/content/websites/maxime-heckel.yaml new file mode 100644 index 0000000..bc36edb --- /dev/null +++ b/content/websites/maxime-heckel.yaml @@ -0,0 +1,2 @@ +title: Maxime Heckel +url: 'https://maximeheckel.com/' diff --git a/content/websites/nico-espeon.yaml b/content/websites/nico-espeon.yaml new file mode 100644 index 0000000..a980cd6 --- /dev/null +++ b/content/websites/nico-espeon.yaml @@ -0,0 +1,2 @@ +title: Nico Espeon +url: 'https://www.nicoespeon.com/' diff --git a/content/websites/not-a-number.yaml b/content/websites/not-a-number.yaml new file mode 100644 index 0000000..d1460e8 --- /dev/null +++ b/content/websites/not-a-number.yaml @@ -0,0 +1,2 @@ +title: Not a Number +url: 'https://www.nan.fyi/' diff --git a/content/websites/paul-scanlon.yaml b/content/websites/paul-scanlon.yaml new file mode 100644 index 0000000..3165742 --- /dev/null +++ b/content/websites/paul-scanlon.yaml @@ -0,0 +1,2 @@ +title: Paul Scanlon +url: 'https://paulie.dev/' diff --git a/content/websites/stefan-judis.yaml b/content/websites/stefan-judis.yaml new file mode 100644 index 0000000..00df3df --- /dev/null +++ b/content/websites/stefan-judis.yaml @@ -0,0 +1,2 @@ +title: Stefan Judis +url: 'https://www.stefanjudis.com/' diff --git a/content/websites/timo-mamecke.yaml b/content/websites/timo-mamecke.yaml new file mode 100644 index 0000000..e86089d --- /dev/null +++ b/content/websites/timo-mamecke.yaml @@ -0,0 +1,2 @@ +title: Timo Mämecke +url: 'https://timomeh.de/' diff --git a/data/cms.ts b/data/cms.ts index 0559f54..dff6df2 100644 --- a/data/cms.ts +++ b/data/cms.ts @@ -61,4 +61,11 @@ export const cms = { return book }, }, + websites: { + async all() { + const websites = await reader.collections.websites.all() + + return websites + }, + }, } diff --git a/data/db/schema.ts b/data/db/schema.ts index 10408c5..20ae617 100644 --- a/data/db/schema.ts +++ b/data/db/schema.ts @@ -1,11 +1,19 @@ +import { customType } from 'drizzle-orm/pg-core' import { index, integer, pgTable, timestamp, + uniqueIndex, varchar, } from 'drizzle-orm/pg-core' +const bytea = customType<{ data: Buffer }>({ + dataType() { + return 'bytea' + }, +}) + export const eventsTable = pgTable( 'events', { @@ -30,3 +38,27 @@ export const testTable = pgTable('test', { .defaultNow() .notNull(), }) + +export const websiteImagesTable = pgTable( + 'website_images', + { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + slug: varchar({ length: 255 }).notNull(), + imageType: varchar('image_type', { length: 10 }).notNull(), + mimeType: varchar('mime_type', { length: 50 }).notNull(), + imageData: bytea('image_data').notNull(), + originalUrl: varchar('original_url', { length: 2048 }), + createdAt: timestamp('created_at', { precision: 6, withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { precision: 6, withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => ({ + slugImageTypeIdx: uniqueIndex('website_images_slug_image_type_idx').on( + table.slug, + table.imageType, + ), + }), +) diff --git a/data/website-images.repo.ts b/data/website-images.repo.ts new file mode 100644 index 0000000..7698710 --- /dev/null +++ b/data/website-images.repo.ts @@ -0,0 +1,90 @@ +import { and, eq } from 'drizzle-orm' + +import { db } from './db/db' +import { websiteImagesTable } from './db/schema' + +export type ImageType = 'og' | 'favicon' + +export class WebsiteImagesRepository { + constructor() { + throw new Error('Not meant to be instantiated') + } + + static async upsert({ + slug, + imageType, + mimeType, + imageData, + originalUrl, + }: { + slug: string + imageType: ImageType + mimeType: string + imageData: Buffer + originalUrl?: string | null + }) { + await db + .insert(websiteImagesTable) + .values({ + slug, + imageType, + mimeType, + imageData, + originalUrl: originalUrl ?? null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [websiteImagesTable.slug, websiteImagesTable.imageType], + set: { + mimeType, + imageData, + originalUrl: originalUrl ?? null, + updatedAt: new Date(), + }, + }) + } + + static async getAllAsDataUriMap(): Promise< + Record + > { + const rows = await db + .select({ + slug: websiteImagesTable.slug, + imageType: websiteImagesTable.imageType, + mimeType: websiteImagesTable.mimeType, + imageData: websiteImagesTable.imageData, + }) + .from(websiteImagesTable) + + const result: Record = {} + + for (const row of rows) { + const existing = result[row.slug] + // OG images take priority over favicons + if (existing && existing.type === 'og') continue + + const base64 = row.imageData.toString('base64') + result[row.slug] = { + type: row.imageType as ImageType, + dataUri: `data:${row.mimeType};base64,${base64}`, + } + } + + return result + } + + static async exists(slug: string, imageType?: ImageType): Promise { + const conditions = [eq(websiteImagesTable.slug, slug)] + if (imageType) { + conditions.push(eq(websiteImagesTable.imageType, imageType)) + } + + const rows = await db + .select({ slug: websiteImagesTable.slug }) + .from(websiteImagesTable) + .where(and(...conditions)) + .limit(1) + + return rows.length > 0 + } +} diff --git a/data/websites.dto.ts b/data/websites.dto.ts new file mode 100644 index 0000000..ace12bb --- /dev/null +++ b/data/websites.dto.ts @@ -0,0 +1,18 @@ +import { unstable_cache } from 'next/cache' + +import { cms } from './cms' +import { WebsiteImagesRepository } from './website-images.repo' + +export async function getAllWebsites() { + const websites = await cms.websites.all() + + return websites.sort((a, b) => a.entry.title.localeCompare(b.entry.title)) +} + +export const getWebsiteImageMap = unstable_cache( + async () => { + return await WebsiteImagesRepository.getAllAsDataUriMap() + }, + ['website-images'], + { revalidate: 86400 }, +) diff --git a/docker-compose.yml b/docker-compose.yml index f0de5fc..f38d058 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,13 @@ services: - RELEASE_VERSION=dev-build ports: - 3000:3000 - db: + website-next-db: image: postgres:latest environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=password - - POSTGRES_DB=chrisjarling-com-dev + - POSTGRES_DB=website-next-dev volumes: - - ./postgres-data:/var/lib/postgresql + - ./website-next-postgres-data:/var/lib/postgresql ports: - 5433:5432 diff --git a/drizzle/0003_marvelous_captain_flint.sql b/drizzle/0003_marvelous_captain_flint.sql new file mode 100644 index 0000000..2f4c054 --- /dev/null +++ b/drizzle/0003_marvelous_captain_flint.sql @@ -0,0 +1,12 @@ +CREATE TABLE "website_images" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "website_images_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "slug" varchar(255) NOT NULL, + "image_type" varchar(10) NOT NULL, + "mime_type" varchar(50) NOT NULL, + "image_data" "bytea" NOT NULL, + "original_url" varchar(2048), + "created_at" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "website_images_slug_image_type_idx" ON "website_images" USING btree ("slug","image_type"); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..a2ef9d8 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,235 @@ +{ + "id": "e92b9ad0-ceb3-4f7d-a5d5-22788132d7ab", + "prevId": "525f250e-2c17-45e3-9c6e-33351608b36d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_created_at_idx": { + "name": "events_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.test": { + "name": "test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "test_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.website_images": { + "name": "website_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "website_images_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "image_type": { + "name": "image_type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "image_data": { + "name": "image_data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "original_url": { + "name": "original_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "website_images_slug_image_type_idx": { + "name": "website_images_slug_image_type_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "image_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 477055a..363c2bd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1769843720090, "tag": "0002_furry_hardball", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1771148508341, + "tag": "0003_marvelous_captain_flint", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/keystatic.config.ts b/keystatic.config.ts index 7db17c8..681beb7 100644 --- a/keystatic.config.ts +++ b/keystatic.config.ts @@ -118,5 +118,20 @@ export default config({ content: fields.markdoc({ label: 'Notes', extension: 'md' }), }, }), + websites: collection({ + label: 'Websites I Like', + slugField: 'title', + path: 'content/websites/*', + schema: { + title: fields.slug({ + name: { label: 'Title' }, + slug: { label: 'Slug' }, + }), + url: fields.text({ + label: 'URL', + validation: { isRequired: true }, + }), + }, + }), }, }) diff --git a/package.json b/package.json index 93a69e5..9a8bafa 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", + "fetch-og-images": "npx tsx scripts/fetch-og-images.ts", + "migrate-images": "npx tsx scripts/migrate-images-to-db.ts", "db:generate": "npx drizzle-kit generate", "db:migrate": "npx drizzle-kit migrate", "db:studio": "npx drizzle-kit studio" @@ -67,6 +69,7 @@ "remark-rehype": "^10.1.0", "remark-wiki-link": "^1.0.4", "serialize-error": "^12.0.0", + "sharp": "^0.34.5", "tar": "^7.5.2", "ua-parser-js": "^2.0.7", "unified": "^10.1.2" @@ -78,6 +81,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/js-yaml": "^4.0.9", "@types/jsdom": "^21.1.7", "@types/node": "18.11.6", "@types/react": "19.2.2", @@ -91,6 +95,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "ignore-loader": "^0.1.2", + "js-yaml": "^4.1.1", "jsdom": "^26.0.0", "postcss": "^8.4.18", "prettier": "^3.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7131c88..6f07ce0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: serialize-error: specifier: ^12.0.0 version: 12.0.0 + sharp: + specifier: ^0.34.5 + version: 0.34.5 tar: specifier: ^7.5.2 version: 7.5.2 @@ -183,6 +186,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/jsdom': specifier: ^21.1.7 version: 21.1.7 @@ -222,6 +228,9 @@ importers: ignore-loader: specifier: ^0.1.2 version: 0.1.2 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 jsdom: specifier: ^26.0.0 version: 26.0.0 @@ -602,8 +611,8 @@ packages: '@effect-ts/system@0.57.5': resolution: {integrity: sha512-/crHGujo0xnuHIYNc1VgP0HGJGFSoSqq88JFXe6FmFyXPpWt8Xu39LyLg7rchsxfXFeEdA9CrIZvLV5eswXV5g==} - '@emnapi/runtime@1.6.0': - resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -1227,138 +1236,151 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.4': - resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.4': - resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.3': - resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.3': - resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.3': - resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.3': - resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.3': - resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.3': - resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.3': - resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': - resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.3': - resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.4': - resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.4': - resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.4': - resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.4': - resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.4': - resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.4': - resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.4': - resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.4': - resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.4': - resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.4': - resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.4': - resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] @@ -2965,6 +2987,9 @@ packages: '@types/is-hotkey@0.1.10': resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -4852,8 +4877,8 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsbi@4.3.0: @@ -6196,8 +6221,8 @@ packages: shallow-equal@1.2.1: resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} - sharp@0.34.4: - resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} shebang-command@2.0.0: @@ -7698,7 +7723,7 @@ snapshots: '@effect-ts/system@0.57.5': {} - '@emnapi/runtime@1.6.0': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -8043,7 +8068,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -8145,93 +8170,100 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} - '@img/colour@1.0.0': - optional: true + '@img/colour@1.0.0': {} - '@img/sharp-darwin-arm64@0.34.4': + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.3 + '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@img/sharp-darwin-x64@0.34.4': + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.3 + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true - '@img/sharp-libvips-darwin-arm64@1.2.3': + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.2.3': + '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.3': + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.2.3': + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.3': + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.3': + '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.3': + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.3': + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true - '@img/sharp-linux-arm64@0.34.4': + '@img/sharp-linux-arm@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.3 + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-arm@0.34.4': + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.3 + '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@img/sharp-linux-ppc64@0.34.4': + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.3 + '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@img/sharp-linux-s390x@0.34.4': + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.3 + '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linux-x64@0.34.4': + '@img/sharp-linux-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.3 + '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.4': + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.4': + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true - '@img/sharp-wasm32@0.34.4': + '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.6.0 + '@emnapi/runtime': 1.8.1 optional: true - '@img/sharp-win32-arm64@0.34.4': + '@img/sharp-win32-arm64@0.34.5': optional: true - '@img/sharp-win32-ia32@0.34.4': + '@img/sharp-win32-ia32@0.34.5': optional: true - '@img/sharp-win32-x64@0.34.4': + '@img/sharp-win32-x64@0.34.5': optional: true '@internationalized/date@3.10.0': @@ -8436,7 +8468,7 @@ snapshots: idb-keyval: 6.2.2 ignore: 5.3.2 is-hotkey: 0.2.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 lib0: 0.2.114 lru-cache: 10.4.3 match-sorter: 6.3.4 @@ -10560,6 +10592,8 @@ snapshots: '@types/is-hotkey@0.1.10': {} + '@types/js-yaml@4.0.9': {} + '@types/jsdom@21.1.7': dependencies: '@types/node': 18.11.6 @@ -11383,8 +11417,7 @@ snapshots: detect-libc@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -12727,7 +12760,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -13885,7 +13918,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.1.0 '@next/swc-win32-x64-msvc': 16.1.0 '@opentelemetry/api': 1.4.1 - sharp: 0.34.4 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -14469,7 +14502,7 @@ snapshots: dependencies: estree-util-is-identifier-name: 1.1.0 estree-util-value-to-estree: 1.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 toml: 3.0.0 remark-mdx@2.3.0: @@ -14709,35 +14742,36 @@ snapshots: shallow-equal@1.2.1: {} - sharp@0.34.4: + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 semver: 7.7.3 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.4 - '@img/sharp-darwin-x64': 0.34.4 - '@img/sharp-libvips-darwin-arm64': 1.2.3 - '@img/sharp-libvips-darwin-x64': 1.2.3 - '@img/sharp-libvips-linux-arm': 1.2.3 - '@img/sharp-libvips-linux-arm64': 1.2.3 - '@img/sharp-libvips-linux-ppc64': 1.2.3 - '@img/sharp-libvips-linux-s390x': 1.2.3 - '@img/sharp-libvips-linux-x64': 1.2.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 - '@img/sharp-linux-arm': 0.34.4 - '@img/sharp-linux-arm64': 0.34.4 - '@img/sharp-linux-ppc64': 0.34.4 - '@img/sharp-linux-s390x': 0.34.4 - '@img/sharp-linux-x64': 0.34.4 - '@img/sharp-linuxmusl-arm64': 0.34.4 - '@img/sharp-linuxmusl-x64': 0.34.4 - '@img/sharp-wasm32': 0.34.4 - '@img/sharp-win32-arm64': 0.34.4 - '@img/sharp-win32-ia32': 0.34.4 - '@img/sharp-win32-x64': 0.34.4 - optional: true + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 shebang-command@2.0.0: dependencies: diff --git a/scripts/fetch-og-images.ts b/scripts/fetch-og-images.ts new file mode 100644 index 0000000..549d795 --- /dev/null +++ b/scripts/fetch-og-images.ts @@ -0,0 +1,244 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { loadEnvConfig } from '@next/env' +import yaml from 'js-yaml' +import sharp from 'sharp' + +import type { WebsiteImagesRepository as Repo } from '../data/website-images.repo' + +loadEnvConfig(process.cwd(), true) + +const CONTENT_DIR = join(process.cwd(), 'content/websites') + +async function getRepo(): Promise { + // Dynamic import after env is loaded so DATABASE_URL is available + const mod = await import('../data/website-images.repo') + return mod.WebsiteImagesRepository +} + +interface WebsiteEntry { + title: string + url: string +} + +function extractOgImage(html: string): string | null { + const match = html.match( + /]*property=["']og:image["'][^>]*content=["']([^"']+)["'][^>]*>/i, + ) + if (match) return match[1] + + const match2 = html.match( + /]*content=["']([^"']+)["'][^>]*property=["']og:image["'][^>]*>/i, + ) + if (match2) return match2[1] + + return null +} + +function extractFaviconUrl(html: string, baseUrl: string): string { + const patterns = [ + /]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["'][^>]*>/i, + /]*href=["']([^"']+)["'][^>]*rel=["']apple-touch-icon["'][^>]*>/i, + /]*rel=["']icon["'][^>]*href=["']([^"']+)["'][^>]*>/i, + /]*href=["']([^"']+)["'][^>]*rel=["']icon["'][^>]*>/i, + /]*rel=["']shortcut icon["'][^>]*href=["']([^"']+)["'][^>]*>/i, + /]*href=["']([^"']+)["'][^>]*rel=["']shortcut icon["'][^>]*>/i, + ] + + for (const pattern of patterns) { + const match = html.match(pattern) + if (match) { + return new URL(match[1], baseUrl).href + } + } + + return new URL('/favicon.ico', baseUrl).href +} + +async function fetchPage( + url: string, +): Promise<{ html: string; finalUrl: string } | null> { + try { + const response = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)', + }, + redirect: 'follow', + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + console.warn(` Failed to fetch ${url}: ${response.status}`) + return null + } + + return { html: await response.text(), finalUrl: response.url } + } catch (error) { + console.warn(` Error fetching ${url}:`, (error as Error).message) + return null + } +} + +async function downloadImage(url: string): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + console.warn(` Failed to fetch image ${url}: ${response.status}`) + return null + } + + return new Uint8Array(await response.arrayBuffer()) + } catch (error) { + console.warn(` Error downloading image ${url}:`, (error as Error).message) + return null + } +} + +function detectMimeType(data: Uint8Array): string { + // Check magic bytes + if (data[0] === 0x89 && data[1] === 0x50) return 'image/png' + if (data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg' + if ( + data[0] === 0x52 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x46 + ) + return 'image/webp' + if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) + return 'image/gif' + if (data[0] === 0x00 && data[1] === 0x00 && data[2] === 0x01) + return 'image/x-icon' + + // Check for SVG (text-based) + const text = new TextDecoder().decode(data.slice(0, 500)) + if (text.includes(' { + return await sharp(Buffer.from(data)) + .resize(512, null, { withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer() +} + +const forceRefetch = process.argv.includes('--force') + +async function main() { + const repo = await getRepo() + + let entries: string[] + try { + entries = await readdir(CONTENT_DIR) + } catch { + console.log('No content/websites directory found, skipping OG image fetch.') + return + } + + const yamlFiles = entries.filter( + (f) => f.endsWith('.yaml') || f.endsWith('.yml'), + ) + + if (yamlFiles.length === 0) { + console.log('No website entries found.') + return + } + + console.log(`Found ${yamlFiles.length} website entries\n`) + + let fetched = 0 + let skipped = 0 + let failed = 0 + + for (const file of yamlFiles) { + const slug = file.replace(/\.ya?ml$/, '') + const content = await readFile(join(CONTENT_DIR, file), 'utf-8') + const data = yaml.load(content) as WebsiteEntry + + console.log(`Processing: ${data.title} (${data.url})`) + + if (!forceRefetch && (await repo.exists(slug))) { + console.log(` Skipped (already in DB)`) + skipped++ + continue + } + + const page = await fetchPage(data.url) + if (!page) { + failed++ + continue + } + + // Try OG image first + const ogImageUrl = extractOgImage(page.html) + if (ogImageUrl) { + const resolvedUrl = new URL(ogImageUrl, page.finalUrl).href + const imageBytes = await downloadImage(resolvedUrl) + if (imageBytes) { + const mime = detectMimeType(imageBytes) + let imageBuffer: Buffer + let mimeType: string + + if (mime === 'image/svg+xml') { + imageBuffer = Buffer.from(imageBytes) + mimeType = 'image/svg+xml' + } else { + imageBuffer = await optimizeOgImage(imageBytes) + mimeType = 'image/webp' + } + + await repo.upsert({ + slug, + imageType: 'og', + mimeType, + imageData: imageBuffer, + originalUrl: resolvedUrl, + }) + console.log( + ` Saved OG image to DB (${(imageBuffer.length / 1024).toFixed(1)} KB)`, + ) + fetched++ + continue + } + } + + // Fall back to favicon + const faviconUrl = extractFaviconUrl(page.html, page.finalUrl) + console.log(` No og:image, trying favicon: ${faviconUrl}`) + const faviconBytes = await downloadImage(faviconUrl) + if (faviconBytes) { + const mimeType = detectMimeType(faviconBytes) + await repo.upsert({ + slug, + imageType: 'favicon', + mimeType, + imageData: Buffer.from(faviconBytes), + originalUrl: faviconUrl, + }) + console.log( + ` Saved favicon to DB (${(faviconBytes.length / 1024).toFixed(1)} KB)`, + ) + fetched++ + } else { + console.warn(` No favicon found either`) + failed++ + } + } + + console.log( + `\nDone! Fetched: ${fetched}, Skipped: ${skipped}, Failed: ${failed}`, + ) + process.exit(0) +} + +main().catch((error) => { + console.error('OG image fetch failed:', error.message) + process.exit(1) +}) diff --git a/scripts/migrate-images-to-db.ts b/scripts/migrate-images-to-db.ts new file mode 100644 index 0000000..f5f1674 --- /dev/null +++ b/scripts/migrate-images-to-db.ts @@ -0,0 +1,111 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { loadEnvConfig } from '@next/env' +import sharp from 'sharp' + +import type { WebsiteImagesRepository as Repo } from '../data/website-images.repo' + +loadEnvConfig(process.cwd(), true) + +const IMAGE_DIR = join(process.cwd(), 'public/website-og-images') + +async function getRepo(): Promise { + const mod = await import('../data/website-images.repo') + return mod.WebsiteImagesRepository +} + +function detectMimeType(data: Buffer): string { + if (data[0] === 0x89 && data[1] === 0x50) return 'image/png' + if (data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg' + if ( + data[0] === 0x52 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x46 + ) + return 'image/webp' + if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) + return 'image/gif' + if (data[0] === 0x00 && data[1] === 0x00 && data[2] === 0x01) + return 'image/x-icon' + + const text = data.subarray(0, 500).toString('utf-8') + if (text.includes(' + /\.(png|jpe?g|webp|gif|svg|ico)$/i.test(f), + ) + + console.log(`Found ${imageFiles.length} image files to migrate\n`) + + let migrated = 0 + let failed = 0 + + for (const file of imageFiles) { + const dotIndex = file.indexOf('.') + const name = file.slice(0, dotIndex) + + const isFavicon = name.endsWith('-favicon') + const slug = isFavicon ? name.slice(0, -'-favicon'.length) : name + const imageType = isFavicon ? 'favicon' : 'og' + + console.log(`Migrating: ${file} (slug=${slug}, type=${imageType})`) + + try { + const data = await readFile(join(IMAGE_DIR, file)) + const detectedMime = detectMimeType(data) + + let imageBuffer: Buffer + let mimeType: string + + if (imageType === 'og' && detectedMime !== 'image/svg+xml') { + imageBuffer = await sharp(data) + .resize(512, null, { withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer() + mimeType = 'image/webp' + } else { + imageBuffer = data + mimeType = detectedMime + } + + await repo.upsert({ + slug, + imageType: imageType as 'og' | 'favicon', + mimeType, + imageData: imageBuffer, + }) + + console.log( + ` OK (${(data.length / 1024).toFixed(1)} KB -> ${(imageBuffer.length / 1024).toFixed(1)} KB)`, + ) + migrated++ + } catch (error) { + console.error(` Failed: ${(error as Error).message}`) + failed++ + } + } + + console.log(`\nDone! Migrated: ${migrated}, Failed: ${failed}`) + process.exit(0) +} + +main().catch((error) => { + console.error('Migration failed:', error.message) + process.exit(1) +})