Configurable link in bio starter kit easily deployed with Deno Deploy.
1import type { Config, Link, Section } from "./types.ts";
2
3// Utility functions
4function escapeHtml(text: string): string {
5 const map: Record<string, string> = {
6 "&": "&",
7 "<": "<",
8 ">": ">",
9 '"': """,
10 "'": "'",
11 };
12 return text.replace(/[&<>"']/g, (m) => map[m]);
13}
14
15// Component rendering functions
16function 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
50function 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
67export 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}