bio-free.openapps.xyz

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.
Owned app · rev 3e98d5e10bac
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.
← All files
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
3e98d5e10b · 4 hours ago
Bounded source sync
c1194348e3 · 1 day ago
Bounded source sync
5b1f3fab35 · 1 day ago
Bounded source sync
21e45889ea · 1 day ago
Bounded source sync
8e21b0e5b7 · 1 day ago
Bounded source sync
0598bcc9b2 · 1 day ago
Bounded source sync
0ea5668865 · 1 day ago