Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 4ce232ee856d7de1ba5f2b6d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/page.ts
// Draws a Bio page as HTML. The public page and the editor's phone preview both use this,
// so what an owner sees while editing is exactly what visitors get. Everything a person typed
// is escaped, and only http(s), mailto and image data addresses reach an attribute.
import { SITE_URL } from "../functions/lib/config";
import { SOCIALS, THEMES, hostOf, isAvatar, type Page, type SocialKind } from "../functions/lib/bio";
export const HOST = SITE_URL.replace(/^https?:\/\//, "");
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const isWeb = (url: string) => /^https?:\/\/[^\s]+$/i.test(url);
const stroke = (body: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
const solid = (body: string) => `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${body}</svg>`;
const butterflyHalf = "M12 10.7C10.4 7.4 6.7 4.3 4.5 3.7 3.2 3.3 2.5 4 2.5 5.4c0 1.2.4 4.3.9 5.3.9 1.9 3 2.4 5.1 2.1-3 .5-4.5 2.2-2.6 4.3 2 2.2 4.2.6 5.3-1.8.4-.9.7-1.8.8-2.4z";
/** Simple one-color icons, drawn on a 24 by 24 grid. */
export const ICONS: Record<SocialKind, string> = {
website: stroke('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/>'),
email: stroke('<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="m4 7.5 8 5.5 8-5.5"/>'),
x: solid('<path transform="translate(3.2 3.2) scale(.733)" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/>'),
instagram: stroke('<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.3" cy="6.7" r="1" fill="currentColor" stroke="none"/>'),
tiktok: stroke('<path d="M14 2.5v13a4 4 0 1 1-4-4"/><path d="M14 2.5c.4 3 2.6 5.2 5.8 5.6"/>'),
youtube: stroke('<rect x="2.5" y="5" width="19" height="14" rx="4"/><path d="m10 9.2 5 2.8-5 2.8z" fill="currentColor"/>'),
linkedin: stroke('<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 10.5V16M12 16v-5.5M12 13a2.5 2.5 0 0 1 5 0v3"/><circle cx="8" cy="7.6" r=".9" fill="currentColor" stroke="none"/>'),
github: solid('<path transform="translate(2.4 2.4) scale(1.2)" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>'),
threads: stroke('<circle cx="12" cy="12" r="3.6"/><path d="M15.6 8.6v4.6a2.9 2.9 0 0 0 5.8 0V12A9.4 9.4 0 1 0 17.7 19.5"/>'),
bluesky: solid(`<path d="${butterflyHalf}"/><path transform="matrix(-1 0 0 1 24 0)" d="${butterflyHalf}"/>`),
facebook: stroke('<circle cx="12" cy="12" r="9"/><path d="M15 8h-1.5a2.5 2.5 0 0 0-2.5 2.5V21M8.5 13H14"/>'),
whatsapp: stroke('<path d="M4.64 16.25 3.5 20.5l4.25-1.14A8.5 8.5 0 1 0 4.64 16.25z"/><path fill="currentColor" stroke="none" d="M9.2 7.6c.4-.2.8 0 1 .4l.8 1.8c.1.3 0 .7-.2.9l-.6.6c.6 1.2 1.6 2.2 2.8 2.8l.6-.6c.2-.2.6-.3.9-.2l1.8.8c.4.2.6.6.4 1l-.4 1c-.3.6-.9.9-1.5.8-3.3-.6-5.9-3.2-6.5-6.5-.1-.6.2-1.2.8-1.5z"/>'),
telegram: stroke('<path d="M21.2 4.2 2.9 11.3l6 2.2 2.3 6.3 3.2-4 4.9 3.6z"/><path d="m8.9 13.5 8.6-6"/>'),
spotify: stroke('<circle cx="12" cy="12" r="9"/><path d="M7.5 9.8c3-.9 6.6-.6 9.2 1M8 12.9c2.5-.7 5.2-.4 7.4.8M8.6 15.8c1.9-.5 3.9-.3 5.6.6"/>'),
twitch: stroke('<path d="M4.5 3h15v10.5l-4 4h-4l-3 3v-3h-4z"/><path d="M11 7.5v4M15 7.5v4"/>'),
};
/** The address a social icon opens, or null when it is not safe to link. */
function socialHref(kind: SocialKind, value: string): string | null {
if (kind === "email") return /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(value) ? `mailto:${value}` : null;
return isWeb(value) ? value : null;
}
const initial = (name: string) => (Array.from(name.trim())[0] || "?").toUpperCase();
/** The page as HTML. Hidden links are left out. In the preview, empty parts show a hint instead of disappearing,
* and links leave the tab order (the editor's preview is a picture of the page, not a copy to tab through). */
export function pageHtml(p: Page, handle: string, preview = false): string {
const tab = preview ? ' tabindex="-1"' : "";
const theme = THEMES.includes(p.theme) ? p.theme : "paper";
const style = p.buttonStyle === "outline" ? "outline" : "filled";
const name = p.name.trim() || (preview ? "Your name" : handle);
const avatar = isAvatar(p.avatar)
? `<img class="bp-avatar" src="${p.avatar}" alt="" width="96" height="96">`
: `<div class="bp-avatar bp-initial" aria-hidden="true">${esc(initial(name))}</div>`;
const socials = SOCIALS.map(({ kind, label }) => {
const value = p.socials[kind];
const href = value ? socialHref(kind, value) : null;
return href ? `<li><a href="${esc(href)}" aria-label="${label}" title="${label}" rel="me noopener"${tab}>${ICONS[kind]}</a></li>` : "";
}).join("");
const links = p.links.filter((l) => !l.hidden).map((l) => {
if (l.header) return `<li class="bp-head"><h2>${esc(l.title.trim() || "Header")}</h2></li>`;
const title = esc(l.title.trim() || (l.url ? hostOf(l.url) : "") || "Untitled link");
return isWeb(l.url)
? `<li><a class="bp-link" href="${esc(l.url)}" data-id="${esc(l.id)}" rel="noopener"${tab}>${title}</a></li>`
: `<li><span class="bp-link">${title}</span></li>`;
}).join("");
return `<div class="bp bp-${theme} bp-${style}"><div class="bp-inner">
${avatar}
<h1 class="bp-name">${esc(name)}</h1>
${p.bio.trim() ? `<p class="bp-bio">${esc(p.bio)}</p>` : ""}
${socials ? `<ul class="bp-socials">${socials}</ul>` : ""}
${links ? `<ul class="bp-links">${links}</ul>` : preview ? `<p class="bp-hint">Your links show up here.</p>` : ""}
${p.showCredit ? `<p class="bp-credit">Made with Bio, free at <a href="${esc(SITE_URL)}/"${tab}>${esc(HOST)}</a></p>` : ""}
</div></div>`;
}
/** The theme's background, for the browser bar and the area around the page. */
export const THEME_BG: Record<string, string> = { paper: "#f8f3ea", ink: "#fdfdfc", cobalt: "#e9effa", sage: "#e8f1ea", night: "#16181d" };
/** The example on the landing page. */
export const SAMPLE: Page = {
name: "Maya Okafor",
bio: "Ceramics and slow mornings in Lisbon. New pieces every first Sunday.",
avatar: "",
theme: "paper",
buttonStyle: "filled",
links: [
{ id: "s1", title: "Shop the October pieces", url: "https://example.com/shop" },
{ id: "s2", title: "Wheel classes in Lisbon", url: "https://example.com/classes" },
{ id: "s3", title: "My newsletter", url: "https://example.com/letters" },
{ id: "s4", title: "Studio tour on YouTube", url: "https://example.com/tour" },
],
socials: { instagram: "https://www.instagram.com/example", tiktok: "https://www.tiktok.com/@example", youtube: "https://www.youtube.com/@example", email: "hello@example.com" },
showCredit: true,
};
Change history
Quality pass: reorder, hide links, headers, QR share, 15 social icons, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync