Configurable link in bio starter kit easily deployed with Deno Deploy.

⭐️ Initial Commit ❤️

+18
.gitignore
··· 1 + # OS files 2 + .DS_Store 3 + .DS_Store? 4 + ._* 5 + .Spotlight-V100 6 + .Trashes 7 + ehthumbs.db 8 + Thumbs.db 9 + 10 + # Editor files 11 + .vscode/ 12 + .idea/ 13 + *.swp 14 + *.swo 15 + *~ 16 + 17 + # Deno 18 + .deno/
+149
README.md
··· 1 + # Deno Links - Lightweight self-hosted Link in Bio (Deploy for free in less than a minute!) 2 + 3 + A minimal, fast, and easy-to-customize link-in-bio page built with Deno. All content and styling controlled by a single `config.json` file. 4 + 5 + ## Features 6 + 7 + - **Zero dependencies** - Just Deno runtime 8 + - **No build step** - TypeScript works natively 9 + - **Single config file** - All content and styling in `config.json` 10 + - **Fast** - Lightweight, styled with Tailwind CDN 11 + - **Easy to customize** - Edit config and refresh 12 + 13 + ## Prerequisites (for local testing / development) 14 + 15 + Install Deno 16 + 17 + ```bash 18 + # macOS/Linux 19 + curl -fsSL https://deno.land/x/install/install.sh | sh 20 + 21 + # Windows 22 + irm https://deno.land/install.ps1 | iex 23 + ``` 24 + 25 + ## Quick Start 26 + 27 + ```bash 28 + 1. Clone or download this repository 29 + 30 + 2. Modify the config to suit your needs 31 + config.json 32 + 33 + 3. Add your images to static/images/ 34 + 35 + - banner.png (recommended: 800x320px) 36 + - profile.png (recommended: 200x200px) 37 + - logos/ (folder for link icons: 24 logos included) 38 + 39 + 4. Edit config.json with your info 40 + 41 + 5. Start the development server 42 + 43 + `deno task dev` 44 + 45 + ``` 46 + 47 + Visit http://localhost:8001 48 + 49 + -- 50 + 51 + ## Configuration 52 + 53 + Edit `config.json` to update your site. All changes take effect on page refresh (with `deno task dev`). 54 + 55 + ### Site Settings 56 + 57 + ```json 58 + { 59 + "site": { 60 + "title": "Your Name - Follow Me !", 61 + "description": "Your description", 62 + } 63 + } 64 + ``` 65 + 66 + ### Theme Colors 67 + 68 + ```json 69 + { 70 + "theme": { 71 + "gradientFrom": "rgb(254, 205, 211)", 72 + "gradientVia": "rgb(252, 231, 243)", 73 + "gradientTo": "rgb(147, 197, 253)", 74 + "defaultCardBgColor": "rgb(249, 168, 212)", 75 + "defaultCardBorder": "rgb(249, 168, 212)", 76 + "textColor": "white" 77 + } 78 + } 79 + ``` 80 + 81 + The `defaultCardBgColor` and `defaultCardBorder` are used for all link cards unless overridden per-link. 82 + 83 + ### Images 84 + 85 + Place your images in `static/images/` and reference them: 86 + 87 + ```json 88 + { 89 + "images": { 90 + "banner": "/images/banner.png", 91 + "profile": "/images/profile.png", 92 + "background": "/images/nnnoise.svg" 93 + } 94 + } 95 + ``` 96 + 97 + ### Sections 98 + 99 + The config uses a `sections` array to organize your links. Each section can have an optional title and contains an array of links. 100 + 101 + **Section properties:** 102 + - `title` (optional): Section header text 103 + - `links`: Array of link objects 104 + 105 + **Link properties:** 106 + - `title`: Link title text 107 + - `description`: Link description text 108 + - `href`: URL to link to 109 + - `image` (optional): Path to icon image, or `null` for placeholder with letter filler 110 + - `featured` (optional): Set to `true` to add shimmer animation 111 + - `cardBgColor` (optional): Override background color for this specific link 112 + - `cardBorder` (optional): Override border color for this specific link 113 + 114 + ## Project Structure 115 + 116 + ``` 117 + deno-links/ 118 + ├── config.json # Your personal config (create from example) 119 + ├── server.ts # HTTP server (~130 lines) 120 + ├── template.ts # HTML generator (~284 lines) 121 + ├── deno.json # Deno tasks and config 122 + ├── static/ 123 + │ └── images/ # Your images 124 + │ └── logos/ # Link icons/logos 125 + └── README.md 126 + ``` 127 + 128 + ## Deployment 129 + 130 + ### Deno Deploy (Free) 131 + 132 + 1. Push to GitHub 133 + 2. Visit https://console.deno.com 134 + 3. Create new project from GitHub repo 135 + 4. Set Entrypoint to server.ts 136 + 4. Done! Auto-deploys on push 137 + 138 + ## Customization Tips 139 + 140 + 1. **Change colors**: Edit `theme` object in config.json 141 + 2. **Add sections**: Add more objects to `sections` array 142 + 3. **Add links**: Add items to any section's `links` array 143 + 4. **Per-link colors**: Add `cardBgColor` and `cardBorder` to individual links 144 + 5. **Change layout**: Edit `template.ts` 145 + 6. **Add new features**: Edit `server.ts` and `template.ts` 146 + 147 + ## License 148 + 149 + MIT
+72
config.json
··· 1 + { 2 + "site": { 3 + "title": "Your Name - Follow Me !", 4 + "description": "Your description here" 5 + }, 6 + "theme": { 7 + "gradientFrom": "rgb(250, 208, 196)", 8 + "gradientVia": "rgb(255, 154, 158)", 9 + "gradientTo": "rgb(255, 94, 77)", 10 + "defaultCardBgColor": "rgb(244, 114, 182)", 11 + "defaultCardBorder": "rgb(244, 114, 182)", 12 + "textColor": "white" 13 + }, 14 + "images": { 15 + "banner": "/images/banner.png", 16 + "profile": "/images/profile.png" 17 + }, 18 + "sections": [ 19 + { 20 + "title": "Hi I'm [Your Name] !! Here are my links...", 21 + "links": [ 22 + { 23 + "title": "My Website 💖", 24 + "description": "Check out my pckt.blog", 25 + "href": "https://pckt.blog/r/XHLDG2GDN7", 26 + "image": "/images/logos/pckt.jpg", 27 + "featured": true 28 + }, 29 + { 30 + "title": "Deno Links on Tangled", 31 + "description": "Check out my code!", 32 + "href": "https://tangled.org/@did:plc:v46ojbiop5ebs5h7gaomixcc/deno-links", 33 + "image": "/images/logos/tangled.jpg", 34 + "featured": false 35 + }, 36 + { 37 + "title": "Support Me ☕", 38 + "description": "Buy me a coffee!", 39 + "href": "https://ko-fi.com/abcbrookie", 40 + "image": "/images/logos/kofi.png", 41 + "featured": false 42 + } 43 + ] 44 + }, 45 + { 46 + "title": "Check these out! 👇", 47 + "links": [ 48 + { 49 + "title": "Strike", 50 + "description": "Send me bitcoins", 51 + "href": "https://strike.me/brooke", 52 + "image": "/images/logos/strike.png", 53 + "cardBgColor": "rgb(167, 139, 250)", 54 + "cardBorder": "rgb(167, 139, 250)" 55 + }, 56 + { 57 + "title": "Check out my mixtape", 58 + "description": "It's out of this world 🚀", 59 + "href": "https://soundcloud.com/", 60 + "image": "/images/logos/soundcloud.png", 61 + "cardBgColor": "rgb(167, 139, 250)", 62 + "cardBorder": "rgb(167, 139, 250)" 63 + } 64 + ] 65 + } 66 + ], 67 + "footer": { 68 + "text": "Made with 💖 by Brookie - ", 69 + "linkText": "Back to Top", 70 + "textColor": "white" 71 + } 72 + }
+19
deno.json
··· 1 + { 2 + "tasks": { 3 + "dev": "deno run --allow-net --allow-read --allow-env --watch server.ts", 4 + "start": "deno run --allow-net --allow-read --allow-env server.ts" 5 + }, 6 + "fmt": { 7 + "useTabs": false, 8 + "lineWidth": 80, 9 + "indentWidth": 2, 10 + "semiColons": true, 11 + "singleQuote": false, 12 + "proseWrap": "preserve" 13 + }, 14 + "lint": { 15 + "rules": { 16 + "tags": ["recommended"] 17 + } 18 + } 19 + }
+72
server.ts
··· 1 + import type { Config } from "./types.ts"; 2 + import { renderPage } from "./template.ts"; 3 + 4 + async function loadConfig(): Promise<Config> { 5 + const configText = await Deno.readTextFile("./config.json"); 6 + return JSON.parse(configText); 7 + } 8 + 9 + async function serveStaticFile(pathname: string): Promise<Response> { 10 + try { 11 + // Security: prevent directory traversal 12 + const sanitizedPath = pathname.replace(/\.\./g, ""); 13 + const filePath = `./static${sanitizedPath}`; 14 + 15 + const file = await Deno.readFile(filePath); 16 + 17 + // Determine content type 18 + const ext = pathname.split(".").pop()?.toLowerCase(); 19 + const contentTypes: Record<string, string> = { 20 + "html": "text/html", 21 + "css": "text/css", 22 + "js": "application/javascript", 23 + "json": "application/json", 24 + "png": "image/png", 25 + "jpg": "image/jpeg", 26 + "jpeg": "image/jpeg", 27 + "gif": "image/gif", 28 + "svg": "image/svg+xml", 29 + "ico": "image/x-icon", 30 + }; 31 + 32 + const contentType = contentTypes[ext || ""] || "application/octet-stream"; 33 + 34 + return new Response(file, { 35 + headers: { "content-type": contentType }, 36 + }); 37 + } catch (error) { 38 + if (error instanceof Deno.errors.NotFound) { 39 + return new Response("Not found", { status: 404 }); 40 + } 41 + console.error("Error serving static file:", error); 42 + return new Response("Internal server error", { status: 500 }); 43 + } 44 + } 45 + 46 + async function handler(req: Request): Promise<Response> { 47 + const url = new URL(req.url); 48 + 49 + // Serve homepage 50 + if (url.pathname === "/") { 51 + try { 52 + const config = await loadConfig(); 53 + const html = renderPage(config); 54 + return new Response(html, { 55 + headers: { "content-type": "text/html; charset=utf-8" }, 56 + }); 57 + } catch (error) { 58 + console.error("Error rendering page:", error); 59 + return new Response("Internal server error", { status: 500 }); 60 + } 61 + } 62 + 63 + // Serve static files 64 + return serveStaticFile(url.pathname); 65 + } 66 + 67 + const port = parseInt(Deno.env.get("PORT") || "8001"); 68 + 69 + console.log(`🦕 Server running at http://localhost:${port}/`); 70 + console.log(`📝 Edit config.json to update content and styling`); 71 + 72 + Deno.serve({ port }, handler);
static/images/banner.jpeg

This is a binary file and will not be displayed.

static/images/banner.png

This is a binary file and will not be displayed.

static/images/logos/amazon.png

This is a binary file and will not be displayed.

static/images/logos/bluesky.png

This is a binary file and will not be displayed.

static/images/logos/discord.png

This is a binary file and will not be displayed.

static/images/logos/facebook.png

This is a binary file and will not be displayed.

static/images/logos/fansly.png

This is a binary file and will not be displayed.

static/images/logos/github.png

This is a binary file and will not be displayed.

static/images/logos/instagram.png

This is a binary file and will not be displayed.

static/images/logos/kofi.png

This is a binary file and will not be displayed.

static/images/logos/leaflet.jpg

This is a binary file and will not be displayed.

static/images/logos/linkedin.png

This is a binary file and will not be displayed.

static/images/logos/onlyfans.png

This is a binary file and will not be displayed.

static/images/logos/pckt.jpg

This is a binary file and will not be displayed.

static/images/logos/pinterest.png

This is a binary file and will not be displayed.

static/images/logos/reddit.png

This is a binary file and will not be displayed.

static/images/logos/snapchat.png

This is a binary file and will not be displayed.

static/images/logos/soundcloud.png

This is a binary file and will not be displayed.

static/images/logos/strike.png

This is a binary file and will not be displayed.

static/images/logos/tangled.jpg

This is a binary file and will not be displayed.

static/images/logos/telegram.png

This is a binary file and will not be displayed.

static/images/logos/tiktok.png

This is a binary file and will not be displayed.

static/images/logos/tumblr.png

This is a binary file and will not be displayed.

static/images/logos/twitch.png

This is a binary file and will not be displayed.

static/images/logos/x.png

This is a binary file and will not be displayed.

static/images/logos/youtube.png

This is a binary file and will not be displayed.

static/images/profile.png

This is a binary file and will not be displayed.

+191
template.ts
··· 1 + import type { Config, Link, Section } from "./types.ts"; 2 + 3 + // Utility functions 4 + function escapeHtml(text: string): string { 5 + const map: Record<string, string> = { 6 + "&": "&amp;", 7 + "<": "&lt;", 8 + ">": "&gt;", 9 + '"': "&quot;", 10 + "'": "&#039;", 11 + }; 12 + return text.replace(/[&<>"']/g, (m) => map[m]); 13 + } 14 + 15 + // Component rendering functions 16 + function renderLinkCard(link: Link, config: Config): string { 17 + const bgColor = link.cardBgColor || config.theme.defaultCardBgColor; 18 + const borderColor = link.cardBorder || config.theme.defaultCardBorder; 19 + const shimmerClass = link.featured ? "shimmer-card" : ""; 20 + 21 + const imageContent = link.image 22 + ? `<img class="w-16 h-16 ml-4 rounded-full object-cover" 23 + src="${link.image}" 24 + alt="${escapeHtml(link.title)}" 25 + loading="lazy">` 26 + : `<div class="w-16 h-16 ml-4 rounded-full bg-white/20 flex items-center justify-center text-2xl font-bold"> 27 + ${escapeHtml(link.title.charAt(0).toUpperCase())} 28 + </div>`; 29 + 30 + return ` 31 + <div class="group"> 32 + <a href="${link.href}" target="_blank" rel="noopener noreferrer"> 33 + <div class="shadow-xl text-white border-2 min-h-28 rounded-2xl flex items-center hover:scale-105 transition-transform duration-300 overflow-hidden relative ${shimmerClass}" 34 + style="background-color: ${bgColor}; border-color: ${borderColor}; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);"> 35 + <figure class="flex-shrink-0 relative z-10"> 36 + ${imageContent} 37 + </figure> 38 + <div class="flex-1 p-4 relative z-10"> 39 + <h2 class="text-xl md:text-2xl font-semibold">${ 40 + escapeHtml(link.title) 41 + }</h2> 42 + <p class="lg:text-xl">${link.description}</p> 43 + </div> 44 + </div> 45 + </a> 46 + </div> 47 + `; 48 + } 49 + 50 + function renderSection(section: Section, config: Config): string { 51 + const links = section.links.map((link) => renderLinkCard(link, config)).join( 52 + "\n", 53 + ); 54 + 55 + if (section.title) { 56 + return ` 57 + <div class="md:text-xl px-4 h-20 bg-white shadow-md flex items-center justify-center rounded-2xl font-semibold border-2 border-black"> 58 + ${escapeHtml(section.title)} 59 + </div> 60 + ${links} 61 + `; 62 + } 63 + 64 + return links; 65 + } 66 + 67 + export function renderPage(config: Config): string { 68 + const allSections = config.sections.map((section) => 69 + renderSection(section, config) 70 + ).join("\n"); 71 + 72 + return `<!DOCTYPE html> 73 + <html lang="en"> 74 + <head> 75 + <meta charset="utf-8"> 76 + <meta name="viewport" content="width=device-width, initial-scale=1"> 77 + <meta name="description" content="${escapeHtml(config.site.description)}"> 78 + 79 + <!-- Open Graph --> 80 + <meta property="og:type" content="website"> 81 + <meta property="og:title" content="${escapeHtml(config.site.title)}"> 82 + <meta property="og:description" content="${ 83 + escapeHtml(config.site.description) 84 + }"> 85 + <meta property="og:image" content="${config.images.profile}"> 86 + 87 + <title>${escapeHtml(config.site.title)}</title> 88 + 89 + <!-- Tailwind CSS CDN --> 90 + <script src="https://cdn.tailwindcss.com"></script> 91 + 92 + <style> 93 + html { 94 + background-color: ${config.theme.gradientTo}; 95 + min-height: 100vh; 96 + } 97 + 98 + body { 99 + background: linear-gradient(to bottom, ${config.theme.gradientFrom}, ${config.theme.gradientVia}, ${config.theme.gradientTo}); 100 + background-attachment: fixed; 101 + min-height: 100vh; 102 + } 103 + 104 + @keyframes shimmer { 105 + 100% { 106 + transform: translateX(100%); 107 + } 108 + } 109 + 110 + .shimmer-card::before { 111 + content: ""; 112 + position: absolute; 113 + inset: 0; 114 + background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.3), transparent); 115 + transform: translateX(-100%); 116 + pointer-events: none; 117 + z-index: 1; 118 + animation: shimmer 3s; 119 + } 120 + 121 + .group:hover .shimmer-card::before { 122 + animation: shimmer 3s infinite; 123 + } 124 + 125 + .divider { 126 + display: flex; 127 + flex-direction: row; 128 + align-items: center; 129 + margin: 1rem 0; 130 + } 131 + 132 + .divider::before, 133 + .divider::after { 134 + content: ""; 135 + flex: 1; 136 + height: 1px; 137 + background-color: rgba(0, 0, 0, 0.1); 138 + } 139 + 140 + .divider::before { 141 + margin-right: 1rem; 142 + } 143 + 144 + .divider::after { 145 + margin-left: 1rem; 146 + } 147 + </style> 148 + </head> 149 + 150 + <body class="min-h-screen"> 151 + <div class="w-full max-w-2xl min-h-screen mx-auto pt-4 px-4 relative"> 152 + <!-- Banner --> 153 + <img class="object-cover object-left-bottom w-full h-40 shadow-xl rounded-xl" 154 + src="${config.images.banner}" 155 + alt="Banner" /> 156 + 157 + <!-- Profile Picture --> 158 + <div class="absolute top-24 left-8"> 159 + <div class="w-24 h-24 rounded-full border-4 border-white flex items-center justify-center overflow-hidden"> 160 + <img class="w-full h-full object-cover" 161 + src="${config.images.profile}" 162 + alt="Profile" /> 163 + </div> 164 + </div> 165 + 166 + <!-- Content --> 167 + <div class="w-full mx-auto px-2 space-y-4 mt-8"> 168 + <!-- Sections --> 169 + ${allSections} 170 + 171 + <!-- Footer --> 172 + <div class="w-full h-16 py-4 text-center font-bold" style="color: ${config.footer.textColor};"> 173 + ${escapeHtml(config.footer.text)} 174 + <a href="#" onclick="scrollToTop(); return false;" class="underline">${ 175 + escapeHtml(config.footer.linkText) 176 + }</a> 177 + </div> 178 + </div> 179 + </div> 180 + 181 + <script> 182 + function scrollToTop() { 183 + window.scrollTo({ 184 + top: 0, 185 + behavior: 'smooth' 186 + }); 187 + } 188 + </script> 189 + </body> 190 + </html>`; 191 + }
+40
types.ts
··· 1 + export interface Link { 2 + title: string; 3 + description: string; 4 + href: string; 5 + image?: string | null; 6 + featured?: boolean; 7 + cardBgColor?: string; 8 + cardBorder?: string; 9 + } 10 + 11 + export interface Section { 12 + title?: string; 13 + links: Link[]; 14 + } 15 + 16 + export interface Config { 17 + site: { 18 + title: string; 19 + description: string; 20 + }; 21 + theme: { 22 + gradientFrom: string; 23 + gradientVia: string; 24 + gradientTo: string; 25 + defaultCardBgColor: string; 26 + defaultCardBorder: string; 27 + textColor: string; 28 + }; 29 + images: { 30 + banner: string; 31 + profile: string; 32 + background: string; 33 + }; 34 + sections: Section[]; 35 + footer: { 36 + text: string; 37 + linkText: string; 38 + textColor: string; 39 + }; 40 + }