import { readFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { createElement as h } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { Output, Route, Site, Spec } from "kit/ssg/build.ts";
import type { Leaf as GitLeaf } from "kit/git/git.ts";
import { loadEnv } from "../lib/env.ts";
import { tree } from "../lib/tree.js";
import { grid, ogUrl } from "../lib/shop.ts";
import { kit } from "./kit.ts";
import site from "../site.json";
loadEnv();
await kit();
const { build, escape } = await import("kit/ssg/build.ts");
const { isGit } = await import("kit/git/git.ts");
const { resolve: resolveLink } = await import("kit/ssg/links.ts");
const { headScript, tintCss } = await import("kit/ui/config.js");
const R = await import("../lib/render.jsx");
const org = resolve(import.meta.dir, "..");
const dist = join(org, "dist");
const root = (process.env.SITE_URL ?? site.root).replace(/\/$/, "");
const SHOP = process.env.SHOPIFY_SHOP_URL ?? "";
const DAY = 24 * 60 * 60 * 1000;
const FALLBACK = `${root}/icon-512.png`;
const read = (path: string) => readFileSync(path, "utf8");
const slugify = (text: string) => String(text ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
/* MARKDOWN */
const marks = (html: string) =>
html
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1");
const inline = (text: string, href?: (url: string) => string) => {
let out = "";
let at = 0;
for (const hit of text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) {
out += escape(text.slice(at, hit.index)) + `${escape(hit[1])}`;
at = hit.index! + hit[0].length;
}
return marks(out + escape(text.slice(at)));
};
function markdown(text: string, href?: (url: string) => string) {
const out: string[] = [];
for (const block of text.trim().split(/\n{2,}/)) {
const line = block.trim();
if (!line) continue;
const head = line.match(/^(#{1,3})\s+(.*)$/);
if (head) out.push(`${inline(head[2], href)}`);
else if (line.startsWith("- ")) out.push(`
${line.split("\n").map((item) => `- ${inline(item.replace(/^- /, ""), href)}
`).join("")}
`);
else out.push(`${inline(line.replace(/\n/g, " "), href)}
`);
}
return out.join("\n");
}
function sheet(text: string, href?: (url: string) => string) {
const blocks = text.trim().split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
let title = "";
let lead = "";
const rest: string[] = [];
for (const block of blocks) {
const head = !title && block.match(/^#\s+(.*)$/);
if (head) {
title = head[1].trim();
continue;
}
if (title && !lead && !block.startsWith("#") && !block.startsWith("- ")) {
lead = block.replace(/\n/g, " ");
continue;
}
rest.push(block);
}
return { title, lead, body: markdown(rest.join("\n\n"), href) };
}
/* TYPES */
type Row = {
key: string;
type: string;
created: string;
available: boolean;
variants: { id: string; size: string; price: string; available: boolean }[];
images: { url: string; alt: string; style: string }[];
files: string[];
title: string;
category: string;
link: string;
price: string;
variant: string;
};
type Leaf = { route: string; name: string; description: string; image: string; type: string; data?: object; scripts: string[]; code?: boolean };
/* HEAD */
function head(site_: Site, leaf: Leaf) {
const url = root + leaf.route;
const tags = [
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
];
const sheets = ["palette.css", "tokens.css", "base.css", "chrome.css", "fonts/fonts.css", "brand.css", ...(leaf.code ? ["code.css", "seti/seti.css"] : [])];
const css = sheets.map((name) => ``);
const tint = ``;
const js = ["chrome.js", ...leaf.scripts].map((name) => ``);
const ld = leaf.data ? `` : "";
return [...tags, ...css, tint, ld, ...js].filter(Boolean).join("\n");
}
function shell(site_: Site, leaf: Leaf & { body: unknown }) {
const main = renderToStaticMarkup(leaf.body as never);
return `
${headScript(site.prefix)}
${escape(leaf.name === site.name ? site.name : `${leaf.name} ยท ${site.name}`)}
${head(site_, leaf)}
${main}
`;
}
/* DATA */
const CATEGORIES: [string, string][] = [
["accessories", "Accessories"],
["bags", "Bags"],
["kids", "Kids"],
["men", "Men"],
["unisex", "Unisex"],
["women", "Women"],
];
const TABS = ["all", ...CATEGORIES.map(([slug]) => slug)];
const cycle = (slug: string) => {
const at = TABS.indexOf(slug);
return `/collections/${TABS[(at + 1) % TABS.length]}/`;
};
const emoji = (name: string) => (site.emoji as Record)[name] ?? "";
const buyUrl = (id: string) => (SHOP ? `https://${SHOP}/cart/${id}:1` : "/cart/");
function tasks(site_: Site) {
const found = new Map();
for (const file of site_.input("tasks").files) {
try {
const task = JSON.parse(read(file));
if (task.key) found.set(task.key, (task.printfiles ?? []).map((p: { name: string }) => p.name).filter(Boolean));
} catch {
console.warn(`site: ${file} is not a task, skipped`);
}
}
return found;
}
function rows(site_: Site): Row[] {
const source = site_.input("shop");
if (!source.files.length) throw new Error("site: data/shop.json is missing; run bun run snapshot or bun run fake");
const snapshot = JSON.parse(read(source.files[0])) as { at: number; products: Row[] };
const catalog = JSON.parse(read(site_.input("catalog").files[0])) as { id: number; category: string; title: string; link: string }[];
const byType = new Map(catalog.map((entry) => [String(entry.id), entry]));
const files = tasks(site_);
const out: Row[] = [];
for (const product of snapshot.products ?? []) {
const entry = byType.get(String(product.type));
if (!entry) {
console.warn(`site: product type ${product.type} is not in catalog.json, ${product.key} skipped`);
continue;
}
const variant = product.variants[0];
if (!variant) {
console.warn(`site: ${product.key} has no variant, skipped`);
continue;
}
out.push({
...product,
files: files.get(product.key) ?? product.files ?? [],
title: entry.title,
category: entry.category,
link: entry.link,
price: variant.price,
variant: variant.id,
});
}
out.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : a.key < b.key ? 1 : -1));
return out;
}
const newestFirst = (list: Row[]) => {
const seen = new Set();
const first: Row[] = [];
const rest: Row[] = [];
for (const row of list) {
if (seen.has(row.type)) rest.push(row);
else {
seen.add(row.type);
first.push(row);
}
}
return [...first, ...rest];
};
/* COLLECT */
const LEAD = "One design at a time. Every printfile and every tile is a free download.";
const INDEX = "Every design, by category and by product.";
const HERO: Record = { contact: "site-contact", donate: "site-donate" };
const ACTION: Record = {
donate: { href: "https://donate.stripe.com/dRm3cu3XLfHj19e6WW5kk00", name: "Donate" },
};
const PAGES: [string, string, string][] = [
["Cart", "/cart/", "site-cart"],
["About", "/about/", "site-icon"],
["Contact", "/contact/", "site-contact"],
["FAQ", "/faq/", "site-page"],
["Terms", "/terms/", "site-page"],
["Privacy", "/privacy/", "site-page"],
["Donate", "/donate/", "site-donate"],
];
const figure = (stem: string) => ({ dark: `/figures/${stem}-dark.png`, light: `/figures/${stem}-light.png` });
function collect(site_: Site) {
const all = rows(site_);
const catalog = site_.input("catalog").files[0];
const byType = new Map();
for (const row of all) byType.set(row.type, [...(byType.get(row.type) ?? []), row]);
const kinds = [...byType.entries()]
.map(([type, list]) => ({ type, slug: slugify(list[0].title), name: list[0].title, list }))
.sort((a, b) => a.name.localeCompare(b.name));
const nav = tree({
collections: [
{ name: "All", href: "/collections/all/" },
...CATEGORIES.map(([slug, name]) => ({ name, href: `/collections/${slug}/` })),
...kinds.map((kind) => ({ name: kind.name, href: `/collections/${kind.slug}/` })),
],
});
const card = (name: string, href: string, mark: string, list: Row[]) => ({
name,
href,
emoji: mark,
count: list.length,
image: list[0]?.images[0] ?? null,
key: list[0]?.key ?? "",
});
const groups = [
{
name: "By category",
cards: [
card("All", "/collections/all/", emoji("all"), all),
...CATEGORIES.map(([slug, name]) => card(name, `/collections/${slug}/`, emoji(slug), all.filter((row) => row.category === slug))),
],
},
{ name: "By product", cards: kinds.map((kind) => card(kind.name, `/collections/${kind.slug}/`, emoji(kind.list[0].category), kind.list)) },
];
const shelf = (name: string, href: string, list: Row[]) => {
const image = list[0]?.images[0];
const url = image ? grid(image.url, 600) : "";
return {
name,
href,
...(url ? { figure: { dark: url, light: url } } : {}),
text: `${list.length} design${list.length === 1 ? "" : "s"}`,
};
};
const menu = [
{
name: "Collections",
href: "/collections/",
nodes: [
shelf("All", "/collections/all/", all),
...CATEGORIES.map(([slug, name]) => shelf(name, `/collections/${slug}/`, all.filter((row) => row.category === slug))),
...kinds.map((kind) => shelf(kind.name, `/collections/${kind.slug}/`, kind.list)),
],
},
{
name: "Pages",
nodes: PAGES.map(([name, href, stem]) => ({ name, href, figure: figure(stem) })),
},
];
const routes: Route[] = [];
routes.push({ route: "/", kind: "home", name: site.name, data: { products: all }, inputs: [catalog], at: today() });
routes.push({ route: "/collections/", kind: "collections", name: "Collections", data: { groups, count: all.length, image: cover(all) }, inputs: [catalog], at: today() });
for (const [slug, name] of [["all", "All"] as [string, string], ...CATEGORIES]) {
const list = slug === "all" ? all : all.filter((row) => row.category === slug);
routes.push({
route: `/collections/${slug}/`,
kind: "collection",
name,
data: { slug, name, products: newestFirst(list) },
inputs: [catalog],
at: today(),
});
}
for (const kind of kinds) {
routes.push({
route: `/collections/${kind.slug}/`,
kind: "collection",
name: kind.name,
data: { slug: kind.slug, name: kind.name, products: kind.list },
inputs: [catalog],
at: today(),
});
}
for (const row of all) {
const family = byType.get(row.type) ?? [row];
routes.push({
route: `/products/${row.key}/`,
kind: "product",
name: row.title,
data: { product: row, family, slug: slugify(row.title) },
inputs: [catalog],
at: row.created.slice(0, 10),
});
}
routes.push({ route: "/cart/", kind: "cart", name: "Cart", inputs: [catalog], at: today(), hidden: true });
routes.push({ route: "/menu/", kind: "menu", name: "Menu", data: { menu }, at: today() });
for (const file of site_.input("pages").files) {
const name = basename(file, ".md");
routes.push({ route: `/${name}/`, kind: "page", name: sheet(read(file)).title || name, source: file, inputs: [file], at: today() });
}
routes.push({ route: "/404.html", kind: "missing", name: "Not found", hidden: true });
return { routes, nav };
}
const today = () => new Date().toISOString().slice(0, 10);
/* RENDER */
const ogFor = (row: Row) => root + ogUrl(row.key);
const describe = (row: Row) => `${row.title} by ${site.name}. USD ${Number(row.price).toFixed(2)}. One design, ${site.live} days.`;
function cover(products: Row[]) {
const first = products[0];
if (!first) return FALLBACK;
return first.images[0] ? grid(first.images[0].url, 1200) : ogFor(first);
}
function draw(site_: Site, route: Route): Output[] {
const path = route.route === "/404.html" ? "404.html" : `${route.route.replace(/^\/|\/$/g, "")}/index.html`.replace(/^\//, "");
const at = route.route === "/" ? "index.html" : path;
if (route.kind === "home") {
const { products } = route.data as { products: Row[] };
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 }));
return [{ path: at, bytes: shell(site_, { route: route.route, name: site.name, description: LEAD, image: cover(products), type: "website", scripts: ["cart.js"], body }) }];
}
if (route.kind === "collections") {
const { groups, count, image } = route.data as { groups: unknown[]; count: number; image: string };
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 }));
return [{ path: at, bytes: shell(site_, { route: route.route, name: "Collections", description: INDEX, image, type: "website", scripts: ["cart.js"], body }) }];
}
if (route.kind === "collection") {
const { slug, name, products } = route.data as { slug: string; name: string; products: Row[] };
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 }));
const lead = `${products.length} design${products.length === 1 ? "" : "s"} in ${name}.`;
return [{ path: at, bytes: shell(site_, { route: route.route, name, description: lead, image: cover(products), type: "website", scripts: ["cart.js"], body }) }];
}
if (route.kind === "product") return product(site_, route, at);
if (route.kind === "cart") {
const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.Cart, {}));
return [{ path: at, bytes: shell(site_, { route: route.route, name: "Cart", description: "Your cart.", image: FALLBACK, type: "website", scripts: ["cart.js"], body }) }];
}
if (route.kind === "menu") {
const { menu } = route.data as { menu: unknown[] };
const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.Menu, { tree: menu }));
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 }) }];
}
if (route.kind === "page") {
const name = basename(route.source as string, ".md");
const doc = sheet(read(route.source as string), (url) => resolveLink(site_, route.source as string, url));
const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.Doc, { ...doc, hero: HERO[name], action: ACTION[name] }));
return [{ path: at, bytes: shell(site_, { route: route.route, name: doc.title || name, description: doc.lead, image: FALLBACK, type: "website", scripts: ["cart.js"], body }) }];
}
const body = h(R.Page, { route: route.route, nav: site_.nav }, h(R.NotFound, {}));
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 }) }];
}
function product(site_: Site, route: Route, at: string): Output[] {
const { product: row, family, slug } = route.data as { product: Row; family: Row[]; slug: string };
const sizes = row.variants.map((variant) => ({ id: variant.id, size: variant.size, price: variant.price }));
const index = family.findIndex((one) => one.key === row.key);
const expires = new Date(row.created).getTime() + site.live * DAY;
const printful = site.printful + row.link;
const siblings: Record = {};
for (const one of family) {
siblings[one.key] = {
created: one.created,
price: one.price,
variants: one.variants,
images: one.images,
files: one.files,
available: one.available,
buy: buyUrl(one.variants[0]?.id ?? ""),
};
}
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 });
const body = h(
R.Page,
{ route: route.route, nav: site_.nav, controls },
h(R.Product, { product: row, siblings: family, collection: `/collections/${slug}/`, files: row.files, tiles: site.tiles }),
h("script", { type: "application/json", id: "siblings", dangerouslySetInnerHTML: { __html: JSON.stringify(siblings) } }),
);
const data = {
"@context": "https://schema.org",
"@type": "Product",
name: row.title,
sku: row.key,
image: ogFor(row),
description: describe(row),
brand: { "@type": "Brand", name: site.name },
offers: {
"@type": "Offer",
url: root + route.route,
priceCurrency: "USD",
price: Number(row.price).toFixed(2),
availability: row.available ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
},
};
return [
{
path: at,
bytes: shell(site_, {
route: route.route,
name: row.title,
description: describe(row),
image: ogFor(row),
type: "website",
data,
scripts: ["cart.js", "expiry.js", "flip.js"],
body,
}),
},
];
}
/* GIT */
function gitPage(site_: Site, leaf: GitLeaf) {
const article = h("article", { className: "prose", dangerouslySetInnerHTML: { __html: leaf.body } });
const body = h(R.Page, { route: leaf.route, nav: leaf.tree ?? site_.nav, wide: leaf.wide }, article);
return shell(site_, {
route: leaf.route,
name: leaf.name,
description: leaf.description,
image: FALLBACK,
type: leaf.type ?? "website",
code: leaf.code,
scripts: ["cart.js"],
body,
});
}
/* SPEC */
export const spec: Spec = {
root: org,
out: dist,
templates: ["lib", "scripts", "js", "ui"],
collect,
render: draw,
git: { page: gitPage, md: (site_, text, from) => markdown(text, (url) => resolveLink(site_, from, url)) },
asset: (name, body) => (name === "cart.js" ? new TextDecoder().decode(body).replaceAll("{{SHOP}}", SHOP) : body),
};
export async function pages() {
return await build(spec, { manifest: ".cache/manifest.json" });
}
if (import.meta.main) {
const done = await pages();
const code = done.site.routes.filter(isGit).length;
console.log(`site: ${done.site.routes.length} routes, ${code} code pages, ${done.rendered} rendered, ${done.written} files written, ${done.removed} dropped`);
}