From 3c356e1b64fb658f729bbcc2663c3726cab2642a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:32:25 +0000 Subject: [PATCH 1/3] Initial plan From 01b49006b2c504e0551943b75708da15c41a8f8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:45:07 +0000 Subject: [PATCH 2/3] feat: add vercel edge function package Co-authored-by: JacobLinCool <28478594+JacobLinCool@users.noreply.github.com> --- .gitignore | 1 + README.md | 4 + packages/vercel/README.md | 12 + packages/vercel/env.d.ts | 9 + packages/vercel/package.json | 18 + packages/vercel/src/cache.ts | 56 + packages/vercel/src/demo/demo.html | 462 +++++++ packages/vercel/src/demo/google-fonts.ts | 1420 ++++++++++++++++++++++ packages/vercel/src/demo/index.ts | 29 + packages/vercel/src/handler.test.ts | 68 ++ packages/vercel/src/handler.ts | 82 ++ packages/vercel/src/headers.ts | 36 + packages/vercel/src/index.ts | 18 + packages/vercel/src/sanitize.ts | 131 ++ packages/vercel/src/utils.ts | 22 + packages/vercel/tsconfig.json | 17 + packages/vercel/vitest.config.mts | 8 + pnpm-lock.yaml | 19 + 18 files changed, 2412 insertions(+) create mode 100644 packages/vercel/README.md create mode 100644 packages/vercel/env.d.ts create mode 100644 packages/vercel/package.json create mode 100644 packages/vercel/src/cache.ts create mode 100644 packages/vercel/src/demo/demo.html create mode 100644 packages/vercel/src/demo/google-fonts.ts create mode 100644 packages/vercel/src/demo/index.ts create mode 100644 packages/vercel/src/handler.test.ts create mode 100644 packages/vercel/src/handler.ts create mode 100644 packages/vercel/src/headers.ts create mode 100644 packages/vercel/src/index.ts create mode 100644 packages/vercel/src/sanitize.ts create mode 100644 packages/vercel/src/utils.ts create mode 100644 packages/vercel/tsconfig.json create mode 100644 packages/vercel/vitest.config.mts diff --git a/.gitignore b/.gitignore index e2cb510..8cece82 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ lib/ worker.capnp .storage/ +packages/vercel/api/ diff --git a/README.md b/README.md index c65f4bf..ddc6b74 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ To build the image by yourself, use `pnpm build:image` script. See [docker-compose.yml](./docker-compose.yml) for an example. +### Deploy on Vercel + +An Edge Function version is available under [`packages/vercel`](./packages/vercel/README.md). Set the project root to that folder and use `pnpm --filter vercel-edge build` as the build command to produce the `api/[[...path]].js` entrypoint for Vercel. + ## Usage Simply copy the code below, paste it into your `README.md`, and change the path to your leetcode username (case-insensitive). diff --git a/packages/vercel/README.md b/packages/vercel/README.md new file mode 100644 index 0000000..62345b5 --- /dev/null +++ b/packages/vercel/README.md @@ -0,0 +1,12 @@ +# LeetCode Stats Card - Vercel Edge + +This package provides a Vercel Edge Function version of the Cloudflare Worker. + +## Deploying to Vercel + +1. Set the project root in Vercel to `packages/vercel`. +2. Use the build command `pnpm --filter vercel-edge build` to bundle the edge function (it emits `api/[[...path]].js`). +3. Keep the output directory as the project root so the generated `api` folder is deployed. +4. After deployment, the endpoint will be available at `/api` (e.g. `/api/?theme=unicorn`). + +All query options and behaviors match the Cloudflare Worker variant, including the demo page when no username is provided. diff --git a/packages/vercel/env.d.ts b/packages/vercel/env.d.ts new file mode 100644 index 0000000..f10f641 --- /dev/null +++ b/packages/vercel/env.d.ts @@ -0,0 +1,9 @@ +declare module "*.html" { + const content: string; + export default content; +} + +declare module "*.html?raw" { + const content: string; + export default content; +} diff --git a/packages/vercel/package.json b/packages/vercel/package.json new file mode 100644 index 0000000..e62c497 --- /dev/null +++ b/packages/vercel/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "vercel-edge", + "version": "0.0.0", + "scripts": { + "build": "esbuild src/index.ts --outfile=\"api/[[...path]].js\" --bundle --format=esm --target=es2021 --loader:.html=text", + "test": "vitest" + }, + "dependencies": { + "hono": "^4.10.4", + "leetcode-card": "workspace:*" + }, + "devDependencies": { + "esbuild": "^0.25.11", + "typescript": "^5.9.3", + "vitest": "3.2.4" + } +} diff --git a/packages/vercel/src/cache.ts b/packages/vercel/src/cache.ts new file mode 100644 index 0000000..62a8865 --- /dev/null +++ b/packages/vercel/src/cache.ts @@ -0,0 +1,56 @@ +function normalizeKey(request: RequestInfo | URL): string { + if (typeof request === "string") return request; + if (request instanceof Request) return request.url; + return request.toString(); +} + +class MemoryCache implements Cache { + private store = new Map(); + + async match(request: RequestInfo | URL): Promise { + const res = this.store.get(normalizeKey(request)); + return res?.clone(); + } + + async matchAll(request?: RequestInfo | URL): Promise { + const res = request ? await this.match(request) : undefined; + return res ? [res] : []; + } + + async add(request: RequestInfo | URL): Promise { + const res = await fetch(request); + await this.put(request, res); + } + + async addAll(requests: RequestInfo[]): Promise { + await Promise.all(requests.map((req) => this.add(req))); + } + + async put(request: RequestInfo | URL, response: Response): Promise { + this.store.set(normalizeKey(request), response.clone()); + } + + async delete(request: RequestInfo | URL): Promise { + return this.store.delete(normalizeKey(request)); + } + + async keys(request?: RequestInfo | URL): Promise { + if (!request) { + return Array.from(this.store.keys()).map((key) => new Request(key)); + } + const key = normalizeKey(request); + return this.store.has(key) ? [new Request(key)] : []; + } +} + +export async function createCache(): Promise { + if (typeof caches !== "undefined") { + try { + return await caches.open("leetcode"); + } catch (err) { + console.error("Failed to open edge cache", err); + } + } + + return new MemoryCache(); +} diff --git a/packages/vercel/src/demo/demo.html b/packages/vercel/src/demo/demo.html new file mode 100644 index 0000000..4aacfb3 --- /dev/null +++ b/packages/vercel/src/demo/demo.html @@ -0,0 +1,462 @@ + + + + + + + LeetCode Stats Card + + + + + +
+

LeetCode Stats Card

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+
+ +
+ +
+
+ + + + + + + + + + + +
+ + + + diff --git a/packages/vercel/src/demo/google-fonts.ts b/packages/vercel/src/demo/google-fonts.ts new file mode 100644 index 0000000..c1d46f2 --- /dev/null +++ b/packages/vercel/src/demo/google-fonts.ts @@ -0,0 +1,1420 @@ +export const fonts = [ + "ABeeZee", + "Abel", + "Abhaya Libre", + "Abril Fatface", + "Aclonica", + "Acme", + "Actor", + "Adamina", + "Advent Pro", + "Aguafina Script", + "Akaya Kanadaka", + "Akaya Telivigala", + "Akronim", + "Akshar", + "Aladin", + "Alata", + "Alatsi", + "Aldrich", + "Alef", + "Alegreya", + "Alegreya SC", + "Alegreya Sans", + "Alegreya Sans SC", + "Aleo", + "Alex Brush", + "Alfa Slab One", + "Alice", + "Alike", + "Alike Angular", + "Allan", + "Allerta", + "Allerta Stencil", + "Allison", + "Allura", + "Almarai", + "Almendra", + "Almendra Display", + "Almendra SC", + "Alumni Sans", + "Alumni Sans Inline One", + "Amarante", + "Amaranth", + "Amatic SC", + "Amethysta", + "Amiko", + "Amiri", + "Amita", + "Anaheim", + "Andada Pro", + "Andika", + "Andika New Basic", + "Anek Bangla", + "Anek Devanagari", + "Anek Gujarati", + "Anek Gurmukhi", + "Anek Kannada", + "Anek Latin", + "Anek Malayalam", + "Anek Odia", + "Anek Tamil", + "Anek Telugu", + "Angkor", + "Annie Use Your Telescope", + "Anonymous Pro", + "Antic", + "Antic Didone", + "Antic Slab", + "Anton", + "Antonio", + "Anybody", + "Arapey", + "Arbutus", + "Arbutus Slab", + "Architects Daughter", + "Archivo", + "Archivo Black", + "Archivo Narrow", + "Are You Serious", + "Aref Ruqaa", + "Arima Madurai", + "Arimo", + "Arizonia", + "Armata", + "Arsenal", + "Artifika", + "Arvo", + "Arya", + "Asap", + "Asap Condensed", + "Asar", + "Asset", + "Assistant", + "Astloch", + "Asul", + "Athiti", + "Atkinson Hyperlegible", + "Atma", + "Atomic Age", + "Aubrey", + "Audiowide", + "Autour One", + "Average", + "Average Sans", + "Averia Gruesa Libre", + "Averia Libre", + "Averia Sans Libre", + "Averia Serif Libre", + "Azeret Mono", + "B612", + "B612 Mono", + "BIZ UDGothic", + "BIZ UDMincho", + "BIZ UDPGothic", + "BIZ UDPMincho", + "Babylonica", + "Bad Script", + "Bahiana", + "Bahianita", + "Bai Jamjuree", + "Bakbak One", + "Ballet", + "Baloo 2", + "Baloo Bhai 2", + "Baloo Bhaijaan 2", + "Baloo Bhaina 2", + "Baloo Chettan 2", + "Baloo Da 2", + "Baloo Paaji 2", + "Baloo Tamma 2", + "Baloo Tammudu 2", + "Baloo Thambi 2", + "Balsamiq Sans", + "Balthazar", + "Bangers", + "Barlow", + "Barlow Condensed", + "Barlow Semi Condensed", + "Barriecito", + "Barrio", + "Basic", + "Baskervville", + "Battambang", + "Baumans", + "Bayon", + "Be Vietnam Pro", + "Beau Rivage", + "Bebas Neue", + "Belgrano", + "Bellefair", + "Belleza", + "Bellota", + "Bellota Text", + "BenchNine", + "Benne", + "Bentham", + "Berkshire Swash", + "Besley", + "Beth Ellen", + "Bevan", + "BhuTuka Expanded One", + "Big Shoulders Display", + "Big Shoulders Inline Display", + "Big Shoulders Inline Text", + "Big Shoulders Stencil Display", + "Big Shoulders Stencil Text", + "Big Shoulders Text", + "Bigelow Rules", + "Bigshot One", + "Bilbo", + "Bilbo Swash Caps", + "BioRhyme", + "BioRhyme Expanded", + "Birthstone", + "Birthstone Bounce", + "Biryani", + "Bitter", + "Black And White Picture", + "Black Han Sans", + "Black Ops One", + "Blaka", + "Blaka Hollow", + "Blinker", + "Bodoni Moda", + "Bokor", + "Bona Nova", + "Bonbon", + "Bonheur Royale", + "Boogaloo", + "Bowlby One", + "Bowlby One SC", + "Brawler", + "Bree Serif", + "Brygada 1918", + "Bubblegum Sans", + "Bubbler One", + "Buda", + "Buenard", + "Bungee", + "Bungee Hairline", + "Bungee Inline", + "Bungee Outline", + "Bungee Shade", + "Butcherman", + "Butterfly Kids", + "Cabin", + "Cabin Condensed", + "Cabin Sketch", + "Caesar Dressing", + "Cagliostro", + "Cairo", + "Caladea", + "Calistoga", + "Calligraffitti", + "Cambay", + "Cambo", + "Candal", + "Cantarell", + "Cantata One", + "Cantora One", + "Capriola", + "Caramel", + "Carattere", + "Cardo", + "Carme", + "Carrois Gothic", + "Carrois Gothic SC", + "Carter One", + "Castoro", + "Catamaran", + "Caudex", + "Caveat", + "Caveat Brush", + "Cedarville Cursive", + "Ceviche One", + "Chakra Petch", + "Changa", + "Changa One", + "Chango", + "Charm", + "Charmonman", + "Chathura", + "Chau Philomene One", + "Chela One", + "Chelsea Market", + "Chenla", + "Cherish", + "Cherry Cream Soda", + "Cherry Swash", + "Chewy", + "Chicle", + "Chilanka", + "Chivo", + "Chonburi", + "Cinzel", + "Cinzel Decorative", + "Clicker Script", + "Coda", + "Coda Caption", + "Codystar", + "Coiny", + "Combo", + "Comfortaa", + "Comforter", + "Comforter Brush", + "Comic Neue", + "Coming Soon", + "Commissioner", + "Concert One", + "Condiment", + "Content", + "Contrail One", + "Convergence", + "Cookie", + "Copse", + "Corben", + "Corinthia", + "Cormorant", + "Cormorant Garamond", + "Cormorant Infant", + "Cormorant SC", + "Cormorant Unicase", + "Cormorant Upright", + "Courgette", + "Courier Prime", + "Cousine", + "Coustard", + "Covered By Your Grace", + "Crafty Girls", + "Creepster", + "Crete Round", + "Crimson Pro", + "Crimson Text", + "Croissant One", + "Crushed", + "Cuprum", + "Cute Font", + "Cutive", + "Cutive Mono", + "DM Mono", + "DM Sans", + "DM Serif Display", + "DM Serif Text", + "Damion", + "Dancing Script", + "Dangrek", + "Darker Grotesque", + "David Libre", + "Dawning of a New Day", + "Days One", + "Dekko", + "Dela Gothic One", + "Delius", + "Delius Swash Caps", + "Delius Unicase", + "Della Respira", + "Denk One", + "Devonshire", + "Dhurjati", + "Didact Gothic", + "Diplomata", + "Diplomata SC", + "Do Hyeon", + "Dokdo", + "Domine", + "Donegal One", + "Dongle", + "Doppio One", + "Dorsa", + "Dosis", + "DotGothic16", + "Dr Sugiyama", + "Duru Sans", + "Dynalight", + "EB Garamond", + "Eagle Lake", + "East Sea Dokdo", + "Eater", + "Economica", + "Eczar", + "El Messiri", + "Electrolize", + "Elsie", + "Elsie Swash Caps", + "Emblema One", + "Emilys Candy", + "Encode Sans", + "Encode Sans Condensed", + "Encode Sans Expanded", + "Encode Sans SC", + "Encode Sans Semi Condensed", + "Encode Sans Semi Expanded", + "Engagement", + "Englebert", + "Enriqueta", + "Ephesis", + "Epilogue", + "Erica One", + "Esteban", + "Estonia", + "Euphoria Script", + "Ewert", + "Exo", + "Exo 2", + "Expletus Sans", + "Explora", + "Fahkwang", + "Familjen Grotesk", + "Fanwood Text", + "Farro", + "Farsan", + "Fascinate", + "Fascinate Inline", + "Faster One", + "Fasthand", + "Fauna One", + "Faustina", + "Federant", + "Federo", + "Felipa", + "Fenix", + "Festive", + "Finger Paint", + "Fira Code", + "Fira Mono", + "Fira Sans", + "Fira Sans Condensed", + "Fira Sans Extra Condensed", + "Fjalla One", + "Fjord One", + "Flamenco", + "Flavors", + "Fleur De Leah", + "Flow Block", + "Flow Circular", + "Flow Rounded", + "Fondamento", + "Fontdiner Swanky", + "Forum", + "Francois One", + "Frank Ruhl Libre", + "Fraunces", + "Freckle Face", + "Fredericka the Great", + "Fredoka", + "Fredoka One", + "Freehand", + "Fresca", + "Frijole", + "Fruktur", + "Fugaz One", + "Fuggles", + "Fuzzy Bubbles", + "GFS Didot", + "GFS Neohellenic", + "Gabriela", + "Gaegu", + "Gafata", + "Galada", + "Galdeano", + "Galindo", + "Gamja Flower", + "Gayathri", + "Gelasio", + "Gemunu Libre", + "Genos", + "Gentium Basic", + "Gentium Book Basic", + "Geo", + "Georama", + "Geostar", + "Geostar Fill", + "Germania One", + "Gideon Roman", + "Gidugu", + "Gilda Display", + "Girassol", + "Give You Glory", + "Glass Antiqua", + "Glegoo", + "Gloria Hallelujah", + "Glory", + "Gluten", + "Goblin One", + "Gochi Hand", + "Goldman", + "Gorditas", + "Gothic A1", + "Gotu", + "Goudy Bookletter 1911", + "Gowun Batang", + "Gowun Dodum", + "Graduate", + "Grand Hotel", + "Grandstander", + "Grape Nuts", + "Gravitas One", + "Great Vibes", + "Grechen Fuemen", + "Grenze", + "Grenze Gotisch", + "Grey Qo", + "Griffy", + "Gruppo", + "Gudea", + "Gugi", + "Gupter", + "Gurajada", + "Gwendolyn", + "Habibi", + "Hachi Maru Pop", + "Hahmlet", + "Halant", + "Hammersmith One", + "Hanalei", + "Hanalei Fill", + "Handlee", + "Hanuman", + "Happy Monkey", + "Harmattan", + "Headland One", + "Heebo", + "Henny Penny", + "Hepta Slab", + "Herr Von Muellerhoff", + "Hi Melody", + "Hina Mincho", + "Hind", + "Hind Guntur", + "Hind Madurai", + "Hind Siliguri", + "Hind Vadodara", + "Holtwood One SC", + "Homemade Apple", + "Homenaje", + "Hubballi", + "Hurricane", + "IBM Plex Mono", + "IBM Plex Sans", + "IBM Plex Sans Arabic", + "IBM Plex Sans Condensed", + "IBM Plex Sans Devanagari", + "IBM Plex Sans Hebrew", + "IBM Plex Sans KR", + "IBM Plex Sans Thai", + "IBM Plex Sans Thai Looped", + "IBM Plex Serif", + "IM Fell DW Pica", + "IM Fell DW Pica SC", + "IM Fell Double Pica", + "IM Fell Double Pica SC", + "IM Fell English", + "IM Fell English SC", + "IM Fell French Canon", + "IM Fell French Canon SC", + "IM Fell Great Primer", + "IM Fell Great Primer SC", + "Ibarra Real Nova", + "Iceberg", + "Iceland", + "Imbue", + "Imperial Script", + "Imprima", + "Inconsolata", + "Inder", + "Indie Flower", + "Ingrid Darling", + "Inika", + "Inknut Antiqua", + "Inria Sans", + "Inria Serif", + "Inspiration", + "Inter", + "Irish Grover", + "Island Moments", + "Istok Web", + "Italiana", + "Italianno", + "Itim", + "Jacques Francois", + "Jacques Francois Shadow", + "Jaldi", + "JetBrains Mono", + "Jim Nightshade", + "Jockey One", + "Jolly Lodger", + "Jomhuria", + "Jomolhari", + "Josefin Sans", + "Josefin Slab", + "Jost", + "Joti One", + "Jua", + "Judson", + "Julee", + "Julius Sans One", + "Junge", + "Jura", + "Just Another Hand", + "Just Me Again Down Here", + "K2D", + "Kadwa", + "Kaisei Decol", + "Kaisei HarunoUmi", + "Kaisei Opti", + "Kaisei Tokumin", + "Kalam", + "Kameron", + "Kanit", + "Kantumruy", + "Karantina", + "Karla", + "Karma", + "Katibeh", + "Kaushan Script", + "Kavivanar", + "Kavoon", + "Kdam Thmor", + "Keania One", + "Kelly Slab", + "Kenia", + "Khand", + "Khmer", + "Khula", + "Kings", + "Kirang Haerang", + "Kite One", + "Kiwi Maru", + "Klee One", + "Knewave", + "KoHo", + "Kodchasan", + "Koh Santepheap", + "Kolker Brush", + "Kosugi", + "Kosugi Maru", + "Kotta One", + "Koulen", + "Kranky", + "Kreon", + "Kristi", + "Krona One", + "Krub", + "Kufam", + "Kulim Park", + "Kumar One", + "Kumar One Outline", + "Kumbh Sans", + "Kurale", + "La Belle Aurore", + "Lacquer", + "Laila", + "Lakki Reddy", + "Lalezar", + "Lancelot", + "Langar", + "Lateef", + "Lato", + "Lavishly Yours", + "League Gothic", + "League Script", + "League Spartan", + "Leckerli One", + "Ledger", + "Lekton", + "Lemon", + "Lemonada", + "Lexend", + "Lexend Deca", + "Lexend Exa", + "Lexend Giga", + "Lexend Mega", + "Lexend Peta", + "Lexend Tera", + "Lexend Zetta", + "Libre Barcode 128", + "Libre Barcode 128 Text", + "Libre Barcode 39", + "Libre Barcode 39 Extended", + "Libre Barcode 39 Extended Text", + "Libre Barcode 39 Text", + "Libre Barcode EAN13 Text", + "Libre Baskerville", + "Libre Bodoni", + "Libre Caslon Display", + "Libre Caslon Text", + "Libre Franklin", + "Licorice", + "Life Savers", + "Lilita One", + "Lily Script One", + "Limelight", + "Linden Hill", + "Literata", + "Liu Jian Mao Cao", + "Livvic", + "Lobster", + "Lobster Two", + "Londrina Outline", + "Londrina Shadow", + "Londrina Sketch", + "Londrina Solid", + "Long Cang", + "Lora", + "Love Light", + "Love Ya Like A Sister", + "Loved by the King", + "Lovers Quarrel", + "Luckiest Guy", + "Lusitana", + "Lustria", + "Luxurious Roman", + "Luxurious Script", + "M PLUS 1", + "M PLUS 1 Code", + "M PLUS 1p", + "M PLUS 2", + "M PLUS Code Latin", + "M PLUS Rounded 1c", + "Ma Shan Zheng", + "Macondo", + "Macondo Swash Caps", + "Mada", + "Magra", + "Maiden Orange", + "Maitree", + "Major Mono Display", + "Mako", + "Mali", + "Mallanna", + "Mandali", + "Manjari", + "Manrope", + "Mansalva", + "Manuale", + "Marcellus", + "Marcellus SC", + "Marck Script", + "Margarine", + "Markazi Text", + "Marko One", + "Marmelad", + "Martel", + "Martel Sans", + "Marvel", + "Mate", + "Mate SC", + "Maven Pro", + "McLaren", + "Mea Culpa", + "Meddon", + "MedievalSharp", + "Medula One", + "Meera Inimai", + "Megrim", + "Meie Script", + "Meow Script", + "Merienda", + "Merienda One", + "Merriweather", + "Merriweather Sans", + "Metal", + "Metal Mania", + "Metamorphous", + "Metrophobic", + "Michroma", + "Milonga", + "Miltonian", + "Miltonian Tattoo", + "Mina", + "Miniver", + "Miriam Libre", + "Mirza", + "Miss Fajardose", + "Mitr", + "Mochiy Pop One", + "Mochiy Pop P One", + "Modak", + "Modern Antiqua", + "Mogra", + "Mohave", + "Molengo", + "Molle", + "Monda", + "Monofett", + "Monoton", + "Monsieur La Doulaise", + "Montaga", + "Montagu Slab", + "MonteCarlo", + "Montez", + "Montserrat", + "Montserrat Alternates", + "Montserrat Subrayada", + "Moo Lah Lah", + "Moon Dance", + "Moul", + "Moulpali", + "Mountains of Christmas", + "Mouse Memoirs", + "Mr Bedfort", + "Mr Dafoe", + "Mr De Haviland", + "Mrs Saint Delafield", + "Mrs Sheppards", + "Ms Madi", + "Mukta", + "Mukta Mahee", + "Mukta Malar", + "Mukta Vaani", + "Mulish", + "Murecho", + "MuseoModerno", + "My Soul", + "Mystery Quest", + "NTR", + "Nanum Brush Script", + "Nanum Gothic", + "Nanum Gothic Coding", + "Nanum Myeongjo", + "Nanum Pen Script", + "Neonderthaw", + "Nerko One", + "Neucha", + "Neuton", + "New Rocker", + "New Tegomin", + "News Cycle", + "Newsreader", + "Niconne", + "Niramit", + "Nixie One", + "Nobile", + "Nokora", + "Norican", + "Nosifer", + "Notable", + "Nothing You Could Do", + "Noticia Text", + "Noto Emoji", + "Noto Kufi Arabic", + "Noto Music", + "Noto Naskh Arabic", + "Noto Nastaliq Urdu", + "Noto Rashi Hebrew", + "Noto Sans", + "Noto Sans Adlam", + "Noto Sans Adlam Unjoined", + "Noto Sans Anatolian Hieroglyphs", + "Noto Sans Arabic", + "Noto Sans Armenian", + "Noto Sans Avestan", + "Noto Sans Balinese", + "Noto Sans Bamum", + "Noto Sans Bassa Vah", + "Noto Sans Batak", + "Noto Sans Bengali", + "Noto Sans Bhaiksuki", + "Noto Sans Brahmi", + "Noto Sans Buginese", + "Noto Sans Buhid", + "Noto Sans Canadian Aboriginal", + "Noto Sans Carian", + "Noto Sans Caucasian Albanian", + "Noto Sans Chakma", + "Noto Sans Cham", + "Noto Sans Cherokee", + "Noto Sans Coptic", + "Noto Sans Cuneiform", + "Noto Sans Cypriot", + "Noto Sans Deseret", + "Noto Sans Devanagari", + "Noto Sans Display", + "Noto Sans Duployan", + "Noto Sans Egyptian Hieroglyphs", + "Noto Sans Elbasan", + "Noto Sans Elymaic", + "Noto Sans Georgian", + "Noto Sans Glagolitic", + "Noto Sans Gothic", + "Noto Sans Grantha", + "Noto Sans Gujarati", + "Noto Sans Gunjala Gondi", + "Noto Sans Gurmukhi", + "Noto Sans HK", + "Noto Sans Hanifi Rohingya", + "Noto Sans Hanunoo", + "Noto Sans Hatran", + "Noto Sans Hebrew", + "Noto Sans Imperial Aramaic", + "Noto Sans Indic Siyaq Numbers", + "Noto Sans Inscriptional Pahlavi", + "Noto Sans Inscriptional Parthian", + "Noto Sans JP", + "Noto Sans Javanese", + "Noto Sans KR", + "Noto Sans Kaithi", + "Noto Sans Kannada", + "Noto Sans Kayah Li", + "Noto Sans Kharoshthi", + "Noto Sans Khmer", + "Noto Sans Khojki", + "Noto Sans Khudawadi", + "Noto Sans Lao", + "Noto Sans Lepcha", + "Noto Sans Limbu", + "Noto Sans Linear A", + "Noto Sans Linear B", + "Noto Sans Lisu", + "Noto Sans Lycian", + "Noto Sans Lydian", + "Noto Sans Mahajani", + "Noto Sans Malayalam", + "Noto Sans Mandaic", + "Noto Sans Manichaean", + "Noto Sans Marchen", + "Noto Sans Masaram Gondi", + "Noto Sans Math", + "Noto Sans Mayan Numerals", + "Noto Sans Medefaidrin", + "Noto Sans Meetei Mayek", + "Noto Sans Meroitic", + "Noto Sans Miao", + "Noto Sans Modi", + "Noto Sans Mongolian", + "Noto Sans Mono", + "Noto Sans Mro", + "Noto Sans Multani", + "Noto Sans Myanmar", + "Noto Sans N Ko", + "Noto Sans Nabataean", + "Noto Sans New Tai Lue", + "Noto Sans Newa", + "Noto Sans Nushu", + "Noto Sans Ogham", + "Noto Sans Ol Chiki", + "Noto Sans Old Hungarian", + "Noto Sans Old Italic", + "Noto Sans Old North Arabian", + "Noto Sans Old Permic", + "Noto Sans Old Persian", + "Noto Sans Old Sogdian", + "Noto Sans Old South Arabian", + "Noto Sans Old Turkic", + "Noto Sans Oriya", + "Noto Sans Osage", + "Noto Sans Osmanya", + "Noto Sans Pahawh Hmong", + "Noto Sans Palmyrene", + "Noto Sans Pau Cin Hau", + "Noto Sans Phags Pa", + "Noto Sans Phoenician", + "Noto Sans Psalter Pahlavi", + "Noto Sans Rejang", + "Noto Sans Runic", + "Noto Sans SC", + "Noto Sans Samaritan", + "Noto Sans Saurashtra", + "Noto Sans Sharada", + "Noto Sans Shavian", + "Noto Sans Siddham", + "Noto Sans Sinhala", + "Noto Sans Sogdian", + "Noto Sans Sora Sompeng", + "Noto Sans Soyombo", + "Noto Sans Sundanese", + "Noto Sans Syloti Nagri", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Syriac", + "Noto Sans TC", + "Noto Sans Tagalog", + "Noto Sans Tagbanwa", + "Noto Sans Tai Le", + "Noto Sans Tai Tham", + "Noto Sans Tai Viet", + "Noto Sans Takri", + "Noto Sans Tamil", + "Noto Sans Tamil Supplement", + "Noto Sans Telugu", + "Noto Sans Thaana", + "Noto Sans Thai", + "Noto Sans Thai Looped", + "Noto Sans Tifinagh", + "Noto Sans Tirhuta", + "Noto Sans Ugaritic", + "Noto Sans Vai", + "Noto Sans Wancho", + "Noto Sans Warang Citi", + "Noto Sans Yi", + "Noto Sans Zanabazar Square", + "Noto Serif", + "Noto Serif Ahom", + "Noto Serif Armenian", + "Noto Serif Balinese", + "Noto Serif Bengali", + "Noto Serif Devanagari", + "Noto Serif Display", + "Noto Serif Dogra", + "Noto Serif Ethiopic", + "Noto Serif Georgian", + "Noto Serif Grantha", + "Noto Serif Gujarati", + "Noto Serif Gurmukhi", + "Noto Serif Hebrew", + "Noto Serif JP", + "Noto Serif KR", + "Noto Serif Kannada", + "Noto Serif Khmer", + "Noto Serif Lao", + "Noto Serif Malayalam", + "Noto Serif Myanmar", + "Noto Serif Nyiakeng Puachue Hmong", + "Noto Serif SC", + "Noto Serif Sinhala", + "Noto Serif TC", + "Noto Serif Tamil", + "Noto Serif Tangut", + "Noto Serif Telugu", + "Noto Serif Thai", + "Noto Serif Tibetan", + "Noto Serif Yezidi", + "Noto Traditional Nushu", + "Nova Cut", + "Nova Flat", + "Nova Mono", + "Nova Oval", + "Nova Round", + "Nova Script", + "Nova Slim", + "Nova Square", + "Numans", + "Nunito", + "Nunito Sans", + "Odibee Sans", + "Odor Mean Chey", + "Offside", + "Oi", + "Old Standard TT", + "Oldenburg", + "Ole", + "Oleo Script", + "Oleo Script Swash Caps", + "Oooh Baby", + "Open Sans", + "Oranienbaum", + "Orbitron", + "Oregano", + "Orelega One", + "Orienta", + "Original Surfer", + "Oswald", + "Otomanopee One", + "Outfit", + "Over the Rainbow", + "Overlock", + "Overlock SC", + "Overpass", + "Overpass Mono", + "Ovo", + "Oxanium", + "Oxygen", + "Oxygen Mono", + "PT Mono", + "PT Sans", + "PT Sans Caption", + "PT Sans Narrow", + "PT Serif", + "PT Serif Caption", + "Pacifico", + "Padauk", + "Palanquin", + "Palanquin Dark", + "Palette Mosaic", + "Pangolin", + "Paprika", + "Parisienne", + "Passero One", + "Passion One", + "Passions Conflict", + "Pathway Gothic One", + "Patrick Hand", + "Patrick Hand SC", + "Pattaya", + "Patua One", + "Pavanam", + "Paytone One", + "Peddana", + "Peralta", + "Permanent Marker", + "Petemoss", + "Petit Formal Script", + "Petrona", + "Philosopher", + "Piazzolla", + "Piedra", + "Pinyon Script", + "Pirata One", + "Plaster", + "Play", + "Playball", + "Playfair Display", + "Playfair Display SC", + "Plus Jakarta Sans", + "Podkova", + "Poiret One", + "Poller One", + "Poly", + "Pompiere", + "Pontano Sans", + "Poor Story", + "Poppins", + "Port Lligat Sans", + "Port Lligat Slab", + "Potta One", + "Pragati Narrow", + "Praise", + "Prata", + "Preahvihear", + "Press Start 2P", + "Pridi", + "Princess Sofia", + "Prociono", + "Prompt", + "Prosto One", + "Proza Libre", + "Public Sans", + "Puppies Play", + "Puritan", + "Purple Purse", + "Qahiri", + "Quando", + "Quantico", + "Quattrocento", + "Quattrocento Sans", + "Questrial", + "Quicksand", + "Quintessential", + "Qwigley", + "Qwitcher Grypen", + "Racing Sans One", + "Radio Canada", + "Radley", + "Rajdhani", + "Rakkas", + "Raleway", + "Raleway Dots", + "Ramabhadra", + "Ramaraja", + "Rambla", + "Rammetto One", + "Rampart One", + "Ranchers", + "Rancho", + "Ranga", + "Rasa", + "Rationale", + "Ravi Prakash", + "Readex Pro", + "Recursive", + "Red Hat Display", + "Red Hat Mono", + "Red Hat Text", + "Red Rose", + "Redacted", + "Redacted Script", + "Redressed", + "Reem Kufi", + "Reenie Beanie", + "Reggae One", + "Revalia", + "Rhodium Libre", + "Ribeye", + "Ribeye Marrow", + "Righteous", + "Risque", + "Road Rage", + "Roboto", + "Roboto Condensed", + "Roboto Flex", + "Roboto Mono", + "Roboto Serif", + "Roboto Slab", + "Rochester", + "Rock 3D", + "Rock Salt", + "RocknRoll One", + "Rokkitt", + "Romanesco", + "Ropa Sans", + "Rosario", + "Rosarivo", + "Rouge Script", + "Rowdies", + "Rozha One", + "Rubik", + "Rubik Beastly", + "Rubik Bubbles", + "Rubik Glitch", + "Rubik Microbe", + "Rubik Mono One", + "Rubik Moonrocks", + "Rubik Puddles", + "Rubik Wet Paint", + "Ruda", + "Rufina", + "Ruge Boogie", + "Ruluko", + "Rum Raisin", + "Ruslan Display", + "Russo One", + "Ruthie", + "Rye", + "STIX Two Text", + "Sacramento", + "Sahitya", + "Sail", + "Saira", + "Saira Condensed", + "Saira Extra Condensed", + "Saira Semi Condensed", + "Saira Stencil One", + "Salsa", + "Sanchez", + "Sancreek", + "Sansita", + "Sansita Swashed", + "Sarabun", + "Sarala", + "Sarina", + "Sarpanch", + "Sassy Frass", + "Satisfy", + "Sawarabi Gothic", + "Sawarabi Mincho", + "Scada", + "Scheherazade New", + "Schoolbell", + "Scope One", + "Seaweed Script", + "Secular One", + "Sedgwick Ave", + "Sedgwick Ave Display", + "Sen", + "Send Flowers", + "Sevillana", + "Seymour One", + "Shadows Into Light", + "Shadows Into Light Two", + "Shalimar", + "Shanti", + "Share", + "Share Tech", + "Share Tech Mono", + "Shippori Antique", + "Shippori Antique B1", + "Shippori Mincho", + "Shippori Mincho B1", + "Shizuru", + "Shojumaru", + "Short Stack", + "Shrikhand", + "Siemreap", + "Sigmar One", + "Signika", + "Signika Negative", + "Simonetta", + "Single Day", + "Sintony", + "Sirin Stencil", + "Six Caps", + "Skranji", + "Slabo 13px", + "Slabo 27px", + "Slackey", + "Smokum", + "Smooch", + "Smooch Sans", + "Smythe", + "Sniglet", + "Snippet", + "Snowburst One", + "Sofadi One", + "Sofia", + "Solway", + "Song Myung", + "Sonsie One", + "Sora", + "Sorts Mill Goudy", + "Source Code Pro", + "Source Sans 3", + "Source Sans Pro", + "Source Serif 4", + "Source Serif Pro", + "Space Grotesk", + "Space Mono", + "Special Elite", + "Spectral", + "Spectral SC", + "Spicy Rice", + "Spinnaker", + "Spirax", + "Spline Sans", + "Spline Sans Mono", + "Squada One", + "Square Peg", + "Sree Krushnadevaraya", + "Sriracha", + "Srisakdi", + "Staatliches", + "Stalemate", + "Stalinist One", + "Stardos Stencil", + "Stick", + "Stick No Bills", + "Stint Ultra Condensed", + "Stint Ultra Expanded", + "Stoke", + "Strait", + "Style Script", + "Stylish", + "Sue Ellen Francisco", + "Suez One", + "Sulphur Point", + "Sumana", + "Sunflower", + "Sunshiney", + "Supermercado One", + "Sura", + "Suranna", + "Suravaram", + "Suwannaphum", + "Swanky and Moo Moo", + "Syncopate", + "Syne", + "Syne Mono", + "Syne Tactile", + "Tajawal", + "Tangerine", + "Tapestry", + "Taprom", + "Tauri", + "Taviraj", + "Teko", + "Telex", + "Tenali Ramakrishna", + "Tenor Sans", + "Text Me One", + "Texturina", + "Thasadith", + "The Girl Next Door", + "The Nautigal", + "Tienne", + "Tillana", + "Timmana", + "Tinos", + "Tiro Bangla", + "Tiro Devanagari Hindi", + "Tiro Devanagari Marathi", + "Tiro Devanagari Sanskrit", + "Tiro Gurmukhi", + "Tiro Kannada", + "Tiro Tamil", + "Tiro Telugu", + "Titan One", + "Titillium Web", + "Tomorrow", + "Tourney", + "Trade Winds", + "Train One", + "Trirong", + "Trispace", + "Trocchi", + "Trochut", + "Truculenta", + "Trykker", + "Tulpen One", + "Turret Road", + "Twinkle Star", + "Ubuntu", + "Ubuntu Condensed", + "Ubuntu Mono", + "Uchen", + "Ultra", + "Uncial Antiqua", + "Underdog", + "Unica One", + "UnifrakturCook", + "UnifrakturMaguntia", + "Unkempt", + "Unlock", + "Unna", + "Updock", + "Urbanist", + "VT323", + "Vampiro One", + "Varela", + "Varela Round", + "Varta", + "Vast Shadow", + "Vazirmatn", + "Vesper Libre", + "Viaoda Libre", + "Vibes", + "Vibur", + "Vidaloka", + "Viga", + "Voces", + "Volkhov", + "Vollkorn", + "Vollkorn SC", + "Voltaire", + "Vujahday Script", + "Waiting for the Sunrise", + "Wallpoet", + "Walter Turncoat", + "Warnes", + "Water Brush", + "Waterfall", + "Wellfleet", + "Wendy One", + "Whisper", + "WindSong", + "Wire One", + "Work Sans", + "Xanh Mono", + "Yaldevi", + "Yanone Kaffeesatz", + "Yantramanav", + "Yatra One", + "Yellowtail", + "Yeon Sung", + "Yeseva One", + "Yesteryear", + "Yomogi", + "Yrsa", + "Yuji Boku", + "Yuji Hentaigana Akari", + "Yuji Hentaigana Akebono", + "Yuji Mai", + "Yuji Syuku", + "Yusei Magic", + "ZCOOL KuaiLe", + "ZCOOL QingKe HuangYou", + "ZCOOL XiaoWei", + "Zen Antique", + "Zen Antique Soft", + "Zen Dots", + "Zen Kaku Gothic Antique", + "Zen Kaku Gothic New", + "Zen Kurenaido", + "Zen Loop", + "Zen Maru Gothic", + "Zen Old Mincho", + "Zen Tokyo Zoo", + "Zeyada", + "Zhi Mang Xing", + "Zilla Slab", + "Zilla Slab Highlight", +]; diff --git a/packages/vercel/src/demo/index.ts b/packages/vercel/src/demo/index.ts new file mode 100644 index 0000000..6375ab8 --- /dev/null +++ b/packages/vercel/src/demo/index.ts @@ -0,0 +1,29 @@ +import { supported as themes } from "leetcode-card"; +import html from "./demo.html?raw"; +import { fonts } from "./google-fonts"; + +const selected_font = Math.floor(Math.random() * fonts.length); + +export default html + .replace( + "${theme_options}", + Object.keys(themes) + .map( + (theme) => + ``, + ) + .join(""), + ) + .replace( + "${font_options}", + fonts + .map( + (font, i) => + ``, + ) + .join(""), + ); diff --git a/packages/vercel/src/handler.test.ts b/packages/vercel/src/handler.test.ts new file mode 100644 index 0000000..b8ae4cf --- /dev/null +++ b/packages/vercel/src/handler.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let latestGenerator: + | { + config?: Record; + cache?: Cache; + headers: Record; + } + | undefined; + +vi.mock("leetcode-card", async () => { + const actual = await vi.importActual("leetcode-card"); + + class MockGenerator { + public verbose = false; + public config: Record | undefined; + public cache?: Cache; + public headers: Record; + + constructor(cache?: Cache, headers?: Record) { + this.cache = cache; + this.headers = headers ?? {}; + latestGenerator = { cache, headers: this.headers }; + } + + async generate(config: Record): Promise { + this.config = config; + latestGenerator = latestGenerator + ? { ...latestGenerator, config } + : { cache: this.cache, headers: this.headers, config }; + return ""; + } + } + + return { ...actual, Generator: MockGenerator }; +}); + +describe("vercel handler", () => { + beforeEach(() => { + latestGenerator = undefined; + }); + + it("returns demo html when username is missing", async () => { + const { handleRequest } = await import("./handler"); + const res = await handleRequest(new Request("https://example.com/api")); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const body = await res.text(); + expect(body.toLowerCase()).toContain(""); + }); + + it("generates svg using path username and forwards headers", async () => { + const { handleRequest } = await import("./handler"); + const res = await handleRequest( + new Request("https://example.com/api/john?cache=123&width=350", { + headers: { "user-agent": "vitest" }, + }), + ); + + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("public, max-age=123"); + expect(latestGenerator?.config?.username).toBe("john"); + expect(latestGenerator?.config?.width).toBe(350); + expect(latestGenerator?.headers["user-agent"]).toBe("vitest"); + expect(await res.text()).toBe(""); + }); +}); diff --git a/packages/vercel/src/handler.ts b/packages/vercel/src/handler.ts new file mode 100644 index 0000000..9837403 --- /dev/null +++ b/packages/vercel/src/handler.ts @@ -0,0 +1,82 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { Config, Generator } from "leetcode-card"; +import { createCache } from "./cache"; +import demo from "./demo"; +import Header from "./headers"; +import { sanitize } from "./sanitize"; + +const app = new Hono().use("*", cors()); + +app.get("/favicon.ico", (c) => { + return c.redirect( + "https://raw.githubusercontent.com/JacobLinCool/leetcode-stats-card/main/favicon/leetcode.ico", + 301, + ); +}); + +async function generate( + config: Record, + header: Record, +): Promise { + let sanitized: Config; + try { + sanitized = sanitize(config); + } catch (err) { + return new Response((err as Error).message, { + status: 400, + }); + } + console.log("sanitized config", JSON.stringify(sanitized, null, 4)); + + const cache_time = parseInt(config.cache || "300") ?? 300; + const cache = await createCache(); + + const generator = new Generator(cache, header); + generator.verbose = true; + + const headers = new Header().add("cors", "svg"); + headers.set("cache-control", `public, max-age=${cache_time}`); + + return new Response(await generator.generate(sanitized), { headers }); +} + +app.get("/:username", async (c) => { + const username = c.req.param("username"); + const query = Object.fromEntries(new URL(c.req.url).searchParams); + query.username = username; + return await generate(query, { + "user-agent": c.req.header("user-agent") || "Unknown", + }); +}); + +app.get("*", async (c) => { + const query = Object.fromEntries(new URL(c.req.url).searchParams); + + if (!query.username) { + return new Response(demo, { + headers: new Header().add("cors", "html"), + }); + } + + return await generate(query, { + "user-agent": c.req.header("user-agent") || "Unknown", + }); +}); + +app.all("*", () => new Response("Not Found.", { status: 404 })); + +export async function handleRequest(request: Request): Promise { + console.log(`${request.method} ${request.url}`); + const url = new URL(request.url); + if (url.pathname === "/api") { + url.pathname = "/"; + } else if (url.pathname.startsWith("/api/")) { + url.pathname = url.pathname.slice(4); + } + + const normalized = url.toString() === request.url ? request : new Request(url, request); + return app.fetch(normalized); +} + +export { app }; diff --git a/packages/vercel/src/headers.ts b/packages/vercel/src/headers.ts new file mode 100644 index 0000000..67ae1ed --- /dev/null +++ b/packages/vercel/src/headers.ts @@ -0,0 +1,36 @@ +const source = { + cors: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + }, + json: { + "Content-Type": "application/json", + }, + html: { + "Content-Type": "text/html", + }, + text: { + "Content-Type": "text/plain", + }, + svg: { + "Content-Type": "image/svg+xml", + }, +}; + +export default class Header extends Headers { + constructor(headers?: Headers) { + super(headers); + } + + add(...types: (keyof typeof source)[]): Headers { + for (const type of types) { + for (const [key, value] of Object.entries(source[type])) { + this.set(key, value); + } + } + + return this; + } +} diff --git a/packages/vercel/src/index.ts b/packages/vercel/src/index.ts new file mode 100644 index 0000000..cd279bd --- /dev/null +++ b/packages/vercel/src/index.ts @@ -0,0 +1,18 @@ +import { handleRequest } from "./handler"; +import Header from "./headers"; + +export const config = { + runtime: "edge", +}; + +export default async function handler(request: Request): Promise { + try { + return await handleRequest(request); + } catch (err) { + console.error(err); + return new Response((err as Error).message, { + status: 500, + headers: new Header().add("cors", "text"), + }); + } +} diff --git a/packages/vercel/src/sanitize.ts b/packages/vercel/src/sanitize.ts new file mode 100644 index 0000000..93fe282 --- /dev/null +++ b/packages/vercel/src/sanitize.ts @@ -0,0 +1,131 @@ +import { + ActivityExtension, + AnimationExtension, + Config, + ContestExtension, + FontExtension, + HeatmapExtension, + RemoteStyleExtension, + ThemeExtension, +} from "leetcode-card"; +import { booleanize, normalize } from "./utils"; + +// Helper functions to reduce complexity +function handleExtension(config: Record): Config["extensions"] { + const extensions = [FontExtension, AnimationExtension, ThemeExtension]; + + const extName = config.ext || config.extension; + if (extName === "activity") { + extensions.push(ActivityExtension); + } else if (extName === "contest") { + extensions.push(ContestExtension); + } else if (extName === "heatmap") { + extensions.push(HeatmapExtension); + } + + if (config.sheets) { + extensions.push(RemoteStyleExtension); + } + + return extensions; +} + +function handleCssRules(config: Record): string[] { + const css: string[] = []; + + // Handle border radius (backward compatibility) + if (config.border_radius) { + css.push(`#background{rx:${parseFloat(config.border_radius) ?? 1}px}`); + } + + // Handle show_rank (backward compatibility) + if (config.show_rank && booleanize(config.show_rank) === false) { + css.push(`#ranking{display:none}`); + } + + // Handle radius + if (config.radius) { + css.push(`#background{rx:${parseFloat(config.radius) ?? 4}px}`); + } + + // Handle hide elements + if (config.hide) { + const targets = config.hide.split(",").map((x) => x.trim()); + css.push(...targets.map((x) => `#${x}{display:none}`)); + } + + return css; +} + +export function sanitize(config: Record): Config { + if (!config.username?.trim()) { + throw new Error("Missing username"); + } + + const sanitized: Config = { + username: config.username.trim(), + site: config.site?.trim().toLowerCase() === "cn" ? "cn" : "us", + width: parseInt(config.width?.trim()) || 500, + height: parseInt(config.height?.trim()) || 200, + css: [], + extensions: handleExtension(config), + font: normalize(config.font?.trim() || "baloo_2"), + animation: config.animation ? booleanize(config.animation.trim()) : true, + theme: { light: "light", dark: "dark" }, + cache: 60, + }; + + // Handle theme + if (config.theme?.trim()) { + const themes = config.theme.trim().split(","); + sanitized.theme = + themes.length === 1 || themes[1] === "" + ? themes[0].trim() + : { light: themes[0].trim(), dark: themes[1].trim() }; + } + + // Handle custom colors (comma-separated hex values) + if (config.colors) { + const raw = config.colors + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + const hex = raw + .map((c) => (c.startsWith("#") ? c : `#${c}`)) + .map((c) => c.toLowerCase()) + .filter((c) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(c)); + if (hex.length > 0) { + sanitized.colors = hex; + } + } + + // Handle border + if (config.border) { + const size = parseFloat(config.border) ?? 1; + sanitized.extensions.push(() => (generator, data, body, styles) => { + styles.push( + `#background{stroke-width:${size};width:${generator.config.width - size}px;height:${ + generator.config.height - size + }px;transform:translate(${size / 2}px,${size / 2}px)}`, + ); + }); + } + + // Handle CSS rules + sanitized.css = handleCssRules(config); + + // Handle remote style sheets + if (config.sheets) { + sanitized.sheets = config.sheets.split(",").map((x) => x.trim()); + } + + // Handle cache + if (config.cache) { + const cacheValue = parseInt(config.cache); + if (cacheValue >= 0 && cacheValue <= 60 * 60 * 24 * 7) { + sanitized.cache = cacheValue; + } + } + + return sanitized; +} diff --git a/packages/vercel/src/utils.ts b/packages/vercel/src/utils.ts new file mode 100644 index 0000000..36c7c32 --- /dev/null +++ b/packages/vercel/src/utils.ts @@ -0,0 +1,22 @@ +export function normalize(str: string): string { + return str.toLowerCase().split(/[ _]+/g).join("_"); +} + +export function booleanize(value: string | boolean): boolean { + if (typeof value === "boolean") return value; + + const F = [ + "false", + "null", + "0", + "undefined", + "no", + "none", + "off", + "disable", + "disabled", + "nan", + "", + ]; + return !F.includes(value.toLowerCase()); +} diff --git a/packages/vercel/tsconfig.json b/packages/vercel/tsconfig.json new file mode 100644 index 0000000..496f771 --- /dev/null +++ b/packages/vercel/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021", "dom"], + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["env.d.ts", "src/**/*.ts"] +} diff --git a/packages/vercel/vitest.config.mts b/packages/vercel/vitest.config.mts new file mode 100644 index 0000000..16b5a98 --- /dev/null +++ b/packages/vercel/vitest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + assetsInclude: ["**/*.html"], + test: { + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b6d1cd..9f7fb7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,25 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/vercel: + dependencies: + hono: + specifier: ^4.10.4 + version: 4.10.4 + leetcode-card: + specifier: workspace:* + version: link:../core + devDependencies: + esbuild: + specifier: ^0.25.11 + version: 0.25.11 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.9.2)(yaml@2.8.1) + packages: '@ampproject/remapping@2.3.0': From 4b4c02315c5a68f51a864227be34b504a6bf2fc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:47:01 +0000 Subject: [PATCH 3/3] chore: align vercel vitest dependency range Co-authored-by: JacobLinCool <28478594+JacobLinCool@users.noreply.github.com> --- packages/vercel/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vercel/package.json b/packages/vercel/package.json index e62c497..276c3db 100644 --- a/packages/vercel/package.json +++ b/packages/vercel/package.json @@ -13,6 +13,6 @@ "devDependencies": { "esbuild": "^0.25.11", "typescript": "^5.9.3", - "vitest": "3.2.4" + "vitest": "^3.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f7fb7c..e0cec1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,7 +120,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 vitest: - specifier: 3.2.4 + specifier: ^3.2.4 version: 3.2.4(@types/node@24.9.2)(yaml@2.8.1) packages: