build.ts

19.8 kB · typescript · 507 lines

1import { readFileSync } from "node:fs";2import { basename, join, resolve } from "node:path";3import { createElement as h } from "react";4import { renderToStaticMarkup } from "react-dom/server";5import type { Output, Route, Site, Spec } from "kit/ssg/build.ts";6import type { Leaf as GitLeaf } from "kit/git/git.ts";7import { loadEnv } from "../lib/env.ts";8import { tree } from "../lib/tree.js";9import { grid, ogUrl } from "../lib/shop.ts";10import { kit } from "./kit.ts";11import site from "../site.json";1213loadEnv();14await kit();1516const { build, escape } = await import("kit/ssg/build.ts");17const { isGit } = await import("kit/git/git.ts");18const { resolve: resolveLink } = await import("kit/ssg/links.ts");19const { headScript, tintCss } = await import("kit/ui/config.js");20const R = await import("../lib/render.jsx");2122const org = resolve(import.meta.dir, "..");23const dist = join(org, "dist");24const root = (process.env.SITE_URL ?? site.root).replace(/\/$/, "");25const SHOP = process.env.SHOPIFY_SHOP_URL ?? "";26const DAY = 24 * 60 * 60 * 1000;27const FALLBACK = `${root}/icon-512.png`;2829const read = (path: string) => readFileSync(path, "utf8");3031const slugify = (text: string) => String(text ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");3233/* MARKDOWN */3435const marks = (html: string) =>36  html37    .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")38    .replace(/\*([^*]+)\*/g, "<em>$1</em>")39    .replace(/`([^`]+)`/g, "<code>$1</code>");4041const inline = (text: string, href?: (url: string) => string) => {42  let out = "";43  let at = 0;44  for (const hit of text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) {45    out += escape(text.slice(at, hit.index)) + `<a href="${escape(href ? href(hit[2]) : hit[2])}">${escape(hit[1])}</a>`;46    at = hit.index! + hit[0].length;47  }48  return marks(out + escape(text.slice(at)));49};5051function markdown(text: string, href?: (url: string) => string) {52  const out: string[] = [];53  for (const block of text.trim().split(/\n{2,}/)) {54    const line = block.trim();55    if (!line) continue;56    const head = line.match(/^(#{1,3})\s+(.*)$/);57    if (head) out.push(`<h${head[1].length}>${inline(head[2], href)}</h${head[1].length}>`);58    else if (line.startsWith("- ")) out.push(`<ul>${line.split("\n").map((item) => `<li>${inline(item.replace(/^- /, ""), href)}</li>`).join("")}</ul>`);59    else out.push(`<p>${inline(line.replace(/\n/g, " "), href)}</p>`);60  }61  return out.join("\n");62}6364function sheet(text: string, href?: (url: string) => string) {65  const blocks = text.trim().split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);66  let title = "";67  let lead = "";68  const rest: string[] = [];69  for (const block of blocks) {70    const head = !title && block.match(/^#\s+(.*)$/);71    if (head) {72      title = head[1].trim();73      continue;74    }75    if (title && !lead && !block.startsWith("#") && !block.startsWith("- ")) {76      lead = block.replace(/\n/g, " ");77      continue;78    }79    rest.push(block);80  }81  return { title, lead, body: markdown(rest.join("\n\n"), href) };82}8384/* TYPES */8586type Row = {87  key: string;88  type: string;89  created: string;90  available: boolean;91  variants: { id: string; size: string; price: string; available: boolean }[];92  images: { url: string; alt: string; style: string }[];93  files: string[];94  title: string;95  category: string;96  link: string;97  price: string;98  variant: string;99};100101type Leaf = { route: string; name: string; description: string; image: string; type: string; data?: object; scripts: string[]; code?: boolean };102103/* HEAD */104105function head(site_: Site, leaf: Leaf) {106  const url = root + leaf.route;107  const tags = [108    `<link rel="canonical" href="${url}">`,109    `<meta name="description" content="${escape(leaf.description)}">`,110    `<meta property="og:title" content="${escape(leaf.name)}">`,111    `<meta property="og:description" content="${escape(leaf.description)}">`,112    `<meta property="og:url" content="${url}">`,113    `<meta property="og:type" content="${leaf.type}">`,114    `<meta property="og:site_name" content="${escape(site.name)}">`,115    `<meta property="og:image" content="${leaf.image}">`,116    `<meta property="og:image:width" content="1200">`,117    `<meta property="og:image:height" content="1200">`,118    `<meta name="twitter:card" content="summary_large_image">`,119    `<meta name="twitter:image" content="${leaf.image}">`,120    `<link rel="icon" href="/favicon.svg" type="image/svg+xml">`,121    `<link rel="icon" href="/favicon.png" type="image/png" sizes="40x40">`,122    `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`,123    `<link rel="manifest" href="/manifest.webmanifest">`,124  ];125  const sheets = ["palette.css", "tokens.css", "base.css", "chrome.css", "fonts/fonts.css", "brand.css", ...(leaf.code ? ["code.css", "seti/seti.css"] : [])];126  const css = sheets.map((name) => `<link rel="stylesheet" href="${site_.asset(name)}">`);127  const tint = `<style>${tintCss(site.tint)}</style>`;128  const js = ["chrome.js", ...leaf.scripts].map((name) => `<script type="module" src="${site_.asset(name)}"></script>`);129  const ld = leaf.data ? `<script type="application/ld+json">${JSON.stringify(leaf.data)}</script>` : "";130  return [...tags, ...css, tint, ld, ...js].filter(Boolean).join("\n");131}132133function shell(site_: Site, leaf: Leaf & { body: unknown }) {134  const main = renderToStaticMarkup(leaf.body as never);135  return `<!doctype html>136<html lang="en" data-prefix="${site.prefix}">137<head>138<meta charset="utf-8">139<meta name="viewport" content="width=device-width, initial-scale=1">140${headScript(site.prefix)}141<title>${escape(leaf.name === site.name ? site.name : `${leaf.name} · ${site.name}`)}</title>142${head(site_, leaf)}143</head>144<body>145${main}146</body>147</html>148`;149}150151/* DATA */152153const CATEGORIES: [string, string][] = [154  ["accessories", "Accessories"],155  ["bags", "Bags"],156  ["kids", "Kids"],157  ["men", "Men"],158  ["unisex", "Unisex"],159  ["women", "Women"],160];161162const TABS = ["all", ...CATEGORIES.map(([slug]) => slug)];163164const cycle = (slug: string) => {165  const at = TABS.indexOf(slug);166  return `/collections/${TABS[(at + 1) % TABS.length]}/`;167};168169const emoji = (name: string) => (site.emoji as Record<string, string>)[name] ?? "";170171const buyUrl = (id: string) => (SHOP ? `https://${SHOP}/cart/${id}:1` : "/cart/");172173function tasks(site_: Site) {174  const found = new Map<string, string[]>();175  for (const file of site_.input("tasks").files) {176    try {177      const task = JSON.parse(read(file));178      if (task.key) found.set(task.key, (task.printfiles ?? []).map((p: { name: string }) => p.name).filter(Boolean));179    } catch {180      console.warn(`site: ${file} is not a task, skipped`);181    }182  }183  return found;184}185186function rows(site_: Site): Row[] {187  const source = site_.input("shop");188  if (!source.files.length) throw new Error("site: data/shop.json is missing; run bun run snapshot or bun run fake");189  const snapshot = JSON.parse(read(source.files[0])) as { at: number; products: Row[] };190  const catalog = JSON.parse(read(site_.input("catalog").files[0])) as { id: number; category: string; title: string; link: string }[];191  const byType = new Map(catalog.map((entry) => [String(entry.id), entry]));192  const files = tasks(site_);193  const out: Row[] = [];194  for (const product of snapshot.products ?? []) {195    const entry = byType.get(String(product.type));196    if (!entry) {197      console.warn(`site: product type ${product.type} is not in catalog.json, ${product.key} skipped`);198      continue;199    }200    const variant = product.variants[0];201    if (!variant) {202      console.warn(`site: ${product.key} has no variant, skipped`);203      continue;204    }205    out.push({206      ...product,207      files: files.get(product.key) ?? product.files ?? [],208      title: entry.title,209      category: entry.category,210      link: entry.link,211      price: variant.price,212      variant: variant.id,213    });214  }215  out.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : a.key < b.key ? 1 : -1));216  return out;217}218219const newestFirst = (list: Row[]) => {220  const seen = new Set<string>();221  const first: Row[] = [];222  const rest: Row[] = [];223  for (const row of list) {224    if (seen.has(row.type)) rest.push(row);225    else {226      seen.add(row.type);227      first.push(row);228    }229  }230  return [...first, ...rest];231};232233/* COLLECT */234235const LEAD = "One design at a time. Every printfile and every tile is a free download.";236const INDEX = "Every design, by category and by product.";237238const HERO: Record<string, string> = { contact: "site-contact", donate: "site-donate" };239240const ACTION: Record<string, { href: string; name: string }> = {241  donate: { href: "https://donate.stripe.com/dRm3cu3XLfHj19e6WW5kk00", name: "Donate" },242};243244const PAGES: [string, string, string][] = [245  ["Cart", "/cart/", "site-cart"],246  ["About", "/about/", "site-icon"],247  ["Contact", "/contact/", "site-contact"],248  ["FAQ", "/faq/", "site-page"],249  ["Terms", "/terms/", "site-page"],250  ["Privacy", "/privacy/", "site-page"],251  ["Donate", "/donate/", "site-donate"],252];253254const figure = (stem: string) => ({ dark: `/figures/${stem}-dark.png`, light: `/figures/${stem}-light.png` });255256function collect(site_: Site) {257  const all = rows(site_);258  const catalog = site_.input("catalog").files[0];259  const byType = new Map<string, Row[]>();260  for (const row of all) byType.set(row.type, [...(byType.get(row.type) ?? []), row]);261  const kinds = [...byType.entries()]262    .map(([type, list]) => ({ type, slug: slugify(list[0].title), name: list[0].title, list }))263    .sort((a, b) => a.name.localeCompare(b.name));264  const nav = tree({265    collections: [266      { name: "All", href: "/collections/all/" },267      ...CATEGORIES.map(([slug, name]) => ({ name, href: `/collections/${slug}/` })),268      ...kinds.map((kind) => ({ name: kind.name, href: `/collections/${kind.slug}/` })),269    ],270  });271  const card = (name: string, href: string, mark: string, list: Row[]) => ({272    name,273    href,274    emoji: mark,275    count: list.length,276    image: list[0]?.images[0] ?? null,277    key: list[0]?.key ?? "",278  });279  const groups = [280    {281      name: "By category",282      cards: [283        card("All", "/collections/all/", emoji("all"), all),284        ...CATEGORIES.map(([slug, name]) => card(name, `/collections/${slug}/`, emoji(slug), all.filter((row) => row.category === slug))),285      ],286    },287    { name: "By product", cards: kinds.map((kind) => card(kind.name, `/collections/${kind.slug}/`, emoji(kind.list[0].category), kind.list)) },288  ];289  const shelf = (name: string, href: string, list: Row[]) => {290    const image = list[0]?.images[0];291    const url = image ? grid(image.url, 600) : "";292    return {293      name,294      href,295      ...(url ? { figure: { dark: url, light: url } } : {}),296      text: `${list.length} design${list.length === 1 ? "" : "s"}`,297    };298  };299  const menu = [300    {301      name: "Collections",302      href: "/collections/",303      nodes: [304        shelf("All", "/collections/all/", all),305        ...CATEGORIES.map(([slug, name]) => shelf(name, `/collections/${slug}/`, all.filter((row) => row.category === slug))),306        ...kinds.map((kind) => shelf(kind.name, `/collections/${kind.slug}/`, kind.list)),307      ],308    },309    {310      name: "Pages",311      nodes: PAGES.map(([name, href, stem]) => ({ name, href, figure: figure(stem) })),312    },313  ];314  const routes: Route[] = [];315  routes.push({ route: "/", kind: "home", name: site.name, data: { products: all }, inputs: [catalog], at: today() });316  routes.push({ route: "/collections/", kind: "collections", name: "Collections", data: { groups, count: all.length, image: cover(all) }, inputs: [catalog], at: today() });317  for (const [slug, name] of [["all", "All"] as [string, string], ...CATEGORIES]) {318    const list = slug === "all" ? all : all.filter((row) => row.category === slug);319    routes.push({320      route: `/collections/${slug}/`,321      kind: "collection",322      name,323      data: { slug, name, products: newestFirst(list) },324      inputs: [catalog],325      at: today(),326    });327  }328  for (const kind of kinds) {329    routes.push({330      route: `/collections/${kind.slug}/`,331      kind: "collection",332      name: kind.name,333      data: { slug: kind.slug, name: kind.name, products: kind.list },334      inputs: [catalog],335      at: today(),336    });337  }338  for (const row of all) {339    const family = byType.get(row.type) ?? [row];340    routes.push({341      route: `/products/${row.key}/`,342      kind: "product",343      name: row.title,344      data: { product: row, family, slug: slugify(row.title) },345      inputs: [catalog],346      at: row.created.slice(0, 10),347    });348  }349  routes.push({ route: "/cart/", kind: "cart", name: "Cart", inputs: [catalog], at: today(), hidden: true });350  routes.push({ route: "/menu/", kind: "menu", name: "Menu", data: { menu }, at: today() });351  for (const file of site_.input("pages").files) {352    const name = basename(file, ".md");353    routes.push({ route: `/${name}/`, kind: "page", name: sheet(read(file)).title || name, source: file, inputs: [file], at: today() });354  }355  routes.push({ route: "/404.html", kind: "missing", name: "Not found", hidden: true });356  return { routes, nav };357}358359const today = () => new Date().toISOString().slice(0, 10);360361/* RENDER */362363const ogFor = (row: Row) => root + ogUrl(row.key);364365const describe = (row: Row) => `${row.title} by ${site.name}. USD ${Number(row.price).toFixed(2)}. One design, ${site.live} days.`;366367function cover(products: Row[]) {368  const first = products[0];369  if (!first) return FALLBACK;370  return first.images[0] ? grid(first.images[0].url, 1200) : ogFor(first);371}372373function draw(site_: Site, route: Route): Output[] {374  const path = route.route === "/404.html" ? "404.html" : `${route.route.replace(/^\/|\/$/g, "")}/index.html`.replace(/^\//, "");375  const at = route.route === "/" ? "index.html" : path;376  if (route.kind === "home") {377    const { products } = route.data as { products: Row[] };378    const body = h(R.Page, { route: route.route, nav: site_.nav, controls: h(R.Filter, { label: "All", count: products.length, next: cycle("all") }) }, h(R.Home, { products, lead: LEAD }));379    return [{ path: at, bytes: shell(site_, { route: route.route, name: site.name, description: LEAD, image: cover(products), type: "website", scripts: ["cart.js"], body }) }];380  }381  if (route.kind === "collections") {382    const { groups, count, image } = route.data as { groups: unknown[]; count: number; image: string };383    const body = h(R.Page, { route: route.route, nav: site_.nav, controls: h(R.Filter, { label: "All", count, next: cycle("all") }) }, h(R.Collections, { groups, lead: INDEX }));384    return [{ path: at, bytes: shell(site_, { route: route.route, name: "Collections", description: INDEX, image, type: "website", scripts: ["cart.js"], body }) }];385  }386  if (route.kind === "collection") {387    const { slug, name, products } = route.data as { slug: string; name: string; products: Row[] };388    const body = h(R.Page, { route: route.route, nav: site_.nav, controls: h(R.Filter, { label: name, count: products.length, next: cycle(slug) }) }, h(R.Collection, { name, products }));389    const lead = `${products.length} design${products.length === 1 ? "" : "s"} in ${name}.`;390    return [{ path: at, bytes: shell(site_, { route: route.route, name, description: lead, image: cover(products), type: "website", scripts: ["cart.js"], body }) }];391  }392  if (route.kind === "product") return product(site_, route, at);393  if (route.kind === "cart") {394    const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.Cart, {}));395    return [{ path: at, bytes: shell(site_, { route: route.route, name: "Cart", description: "Your cart.", image: FALLBACK, type: "website", scripts: ["cart.js"], body }) }];396  }397  if (route.kind === "menu") {398    const { menu } = route.data as { menu: unknown[] };399    const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.Menu, { tree: menu }));400    return [{ path: at, bytes: shell(site_, { route: route.route, name: "Menu", description: `Every page on ${site.name}.`, image: FALLBACK, type: "website", scripts: ["cart.js"], body }) }];401  }402  if (route.kind === "page") {403    const name = basename(route.source as string, ".md");404    const doc = sheet(read(route.source as string), (url) => resolveLink(site_, route.source as string, url));405    const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.Doc, { ...doc, hero: HERO[name], action: ACTION[name] }));406    return [{ path: at, bytes: shell(site_, { route: route.route, name: doc.title || name, description: doc.lead, image: FALLBACK, type: "website", scripts: ["cart.js"], body }) }];407  }408  const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.NotFound, {}));409  return [{ path: "404.html", bytes: shell(site_, { route: route.route, name: "Not found", description: "That page is gone, or the design expired.", image: FALLBACK, type: "website", scripts: ["cart.js"], body }) }];410}411412function product(site_: Site, route: Route, at: string): Output[] {413  const { product: row, family, slug } = route.data as { product: Row; family: Row[]; slug: string };414  const sizes = row.variants.map((variant) => ({ id: variant.id, size: variant.size, price: variant.price }));415  const index = family.findIndex((one) => one.key === row.key);416  const expires = new Date(row.created).getTime() + site.live * DAY;417  const printful = site.printful + row.link;418  const siblings: Record<string, unknown> = {};419  for (const one of family) {420    siblings[one.key] = {421      created: one.created,422      price: one.price,423      variants: one.variants,424      images: one.images,425      files: one.files,426      available: one.available,427      buy: buyUrl(one.variants[0]?.id ?? ""),428    };429  }430  const controls = h(R.Controls, { product: row, printful, sizes, index: index < 0 ? 0 : index, total: family.length, buy: buyUrl(row.variant), expires, live: site.live * DAY });431  const body = h(432    R.Page,433    { route: route.route, nav: site_.nav, controls },434    h(R.Product, { product: row, siblings: family, collection: `/collections/${slug}/`, files: row.files, tiles: site.tiles }),435    h("script", { type: "application/json", id: "siblings", dangerouslySetInnerHTML: { __html: JSON.stringify(siblings) } }),436  );437  const data = {438    "@context": "https://schema.org",439    "@type": "Product",440    name: row.title,441    sku: row.key,442    image: ogFor(row),443    description: describe(row),444    brand: { "@type": "Brand", name: site.name },445    offers: {446      "@type": "Offer",447      url: root + route.route,448      priceCurrency: "USD",449      price: Number(row.price).toFixed(2),450      availability: row.available ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",451    },452  };453  return [454    {455      path: at,456      bytes: shell(site_, {457        route: route.route,458        name: row.title,459        description: describe(row),460        image: ogFor(row),461        type: "website",462        data,463        scripts: ["cart.js", "expiry.js", "flip.js"],464        body,465      }),466    },467  ];468}469470/* GIT */471472function gitPage(site_: Site, leaf: GitLeaf) {473  const article = h("article", { className: "prose", dangerouslySetInnerHTML: { __html: leaf.body } });474  const body = h(R.Page, { route: leaf.route, nav: leaf.tree ?? site_.nav, wide: leaf.wide }, article);475  return shell(site_, {476    route: leaf.route,477    name: leaf.name,478    description: leaf.description,479    image: FALLBACK,480    type: leaf.type ?? "website",481    code: leaf.code,482    scripts: ["cart.js"],483    body,484  });485}486487/* SPEC */488489export const spec: Spec = {490  root: org,491  out: dist,492  templates: ["lib", "scripts", "js", "ui"],493  collect,494  render: draw,495  git: { page: gitPage, md: (site_, text, from) => markdown(text, (url) => resolveLink(site_, from, url)) },496  asset: (name, body) => (name === "cart.js" ? new TextDecoder().decode(body).replaceAll("{{SHOP}}", SHOP) : body),497};498499export async function pages() {500  return await build(spec, { manifest: ".cache/manifest.json" });501}502503if (import.meta.main) {504  const done = await pages();505  const code = done.site.routes.filter(isGit).length;506  console.log(`site: ${done.site.routes.length} routes, ${code} code pages, ${done.rendered} rendered, ${done.written} files written, ${done.removed} dropped`);507}