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}