blog.ts
7.0 kB · typescript · 242 lines
1import { join, relative } from "node:path";2import { mime, reads } from "../git/git.ts";3import { front } from "./md.ts";4import { bytes, type Bytes, type Input, type Output, type Route, type Site, type Spec } from "./build.ts";56/* TYPES */78export type Card = {9 slug: string;10 route: string;11 title: string;12 date: string;13 lead: string;14 image: string;15 front: Record<string, string>;16};1718export type Post = Card & { body: string; dir: string; source: string; names: string[] };1920export type Leaf = {21 route: string;22 kind: "blog" | "post";23 name: string;24 description: string;25 body: string;26 slug: string;27 date: string;28 lead: string;29 image: string;30 front: Record<string, string>;31 posts: Card[];32 out: Output[];33};3435export type Hooks = {36 page?: (site: Site, leaf: Leaf) => Bytes;37 md?: (site: Site, text: string, from: string, out: Output[]) => string;38};3940export type Drawn = Hooks & { page: NonNullable<Hooks["page"]> };4142/* RULES */4344const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;4546const DATE = /^\d{4}-\d{2}-\d{2}$/;4748const FIGURE = /!\[[^\]]*\]\(\s*<?([^)\s>]+)/;4950const AWAY = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;5152const NEEDS = ["title", "date", "lead"] as const;5354export const BLOG = "/blog/";5556export const postRoute = (slug: string) => `${BLOG}${slug}/`;5758export const isBlog = (route: Route) =>59 route.kind === "blog" || route.kind === "blogpost" || route.kind === "blogfiles";6061/* INPUT */6263function home(site: Site): Input | null {64 if (!site.config.inputs?.blog) return null;65 const one = site.input("blog");66 return one.missing || !one.files.length ? null : one;67}6869/* FIGURE */7071function tidy(url: string): string {72 const cut = url.search(/[#?]/);73 const out: string[] = [];74 for (const part of (cut < 0 ? url : url.slice(0, cut)).split("/")) {75 if (part === "" || part === ".") continue;76 if (part !== "..") out.push(part);77 else if (out.length) out.pop();78 else return "";79 }80 return out.join("/");81}8283function shown(body: string, names: Set<string>, slug: string): string {84 const hit = body.match(FIGURE);85 if (!hit) return "";86 const url = hit[1]!;87 if (AWAY.test(url)) return "";88 const path = tidy(url);89 if (path && names.has(path)) return `${postRoute(slug)}${path}`;90 return SLUG.test(url) ? url : "";91}9293/* POSTS */9495const POSTS = new WeakMap<Site, Post[]>();9697function gather(one: Input): Post[] {98 const groups = new Map<string, string[]>();99 for (const file of one.files) {100 const path = relative(one.path, file);101 const cut = path.indexOf("/");102 if (cut < 0) throw new Error(`blog: ${path} sits loose in the blog folder; a post is <slug>/index.md with its files beside it`);103 const slug = path.slice(0, cut);104 const list = groups.get(slug);105 if (list) list.push(path.slice(cut + 1));106 else groups.set(slug, [path.slice(cut + 1)]);107 }108 const out: Post[] = [];109 for (const slug of [...groups.keys()].sort()) {110 if (!SLUG.test(slug)) throw new Error(`blog: ${slug} is not a slug; use lowercase words and digits joined by hyphens`);111 const inner = groups.get(slug)!.sort();112 if (!inner.includes("index.md")) throw new Error(`blog: ${slug} has no index.md`);113 const dir = join(one.path, slug);114 const source = join(dir, "index.md");115 const { data, body } = front(new TextDecoder().decode(bytes(source)));116 for (const key of NEEDS) if (!data[key]) throw new Error(`blog: ${slug} names no ${key} in its front matter`);117 if (!DATE.test(data.date!)) throw new Error(`blog: ${slug} dates itself ${data.date}; a date is YYYY-MM-DD`);118 const names = inner.filter((path) => path !== "index.md");119 out.push({120 slug,121 route: postRoute(slug),122 title: data.title!,123 date: data.date!,124 lead: data.lead!,125 image: shown(body, new Set(names), slug),126 front: data,127 body,128 dir,129 source,130 names,131 });132 }133 return out.sort((a, b) => (a.date === b.date ? a.slug.localeCompare(b.slug) : a.date < b.date ? 1 : -1));134}135136export function posts(site: Site): Post[] {137 const hit = POSTS.get(site);138 if (hit) return hit;139 const one = home(site);140 const list = one ? gather(one) : [];141 POSTS.set(site, list);142 return list;143}144145const card = (one: Post): Card => ({146 slug: one.slug,147 route: one.route,148 title: one.title,149 date: one.date,150 lead: one.lead,151 image: one.image,152 front: one.front,153});154155/* COLLECT */156157export function collect(site: Site): { routes: Route[] } {158 const list = posts(site);159 if (!list.length) return { routes: [] };160 const routes: Route[] = [161 {162 route: BLOG,163 kind: "blog",164 name: "Blog",165 data: list.map((one) => one.slug),166 source: home(site)!.path,167 inputs: list.map((one) => one.source),168 at: list[0]!.date,169 },170 ];171 for (const one of list) {172 routes.push({173 route: one.route,174 kind: "blogpost",175 name: one.title,176 data: { slug: one.slug, image: one.image },177 source: one.source,178 inputs: [one.source],179 at: one.date,180 });181 if (!one.names.length) continue;182 for (const path of one.names) site.ships.set(join(one.dir, path), `${one.route}${path}`);183 routes.push({184 route: `${one.route}@files`,185 kind: "blogfiles",186 name: one.slug,187 data: { slug: one.slug },188 inputs: [one.dir],189 hidden: true,190 urls: one.names191 .filter((path) => !path.endsWith(".md"))192 .map((path) => ({ route: `${one.route}${path}`, name: path, source: join(one.dir, path), at: one.date })),193 });194 }195 return { routes };196}197198/* RENDER */199200const only = (list: Post[], slug: string) => {201 const hit = list.find((one) => one.slug === slug);202 if (!hit) throw new Error(`blog: no post named ${slug}`);203 return hit;204};205206export function render(site: Site, route: Route, spec: Spec): Output[] {207 const hooks = spec.blog as Drawn | undefined;208 if (!hooks?.page) throw new Error("blog: the site declares a blog input but the spec carries no blog.page");209 const list = posts(site);210 const { slug } = (route.data ?? {}) as { slug?: string };211 if (route.kind === "blogfiles") {212 const one = only(list, slug!);213 return one.names.map((path) => {214 const body = bytes(join(one.dir, path));215 return { path: `blog/${one.slug}/${path}`, bytes: body, type: mime(path, reads(body) !== null) };216 });217 }218 const out: Output[] = [];219 const leaf: Leaf = {220 route: route.route,221 kind: "blog",222 name: route.name ?? "Blog",223 description: "",224 body: "",225 slug: "",226 date: list[0]?.date ?? "",227 lead: "",228 image: "",229 front: {},230 posts: list.map(card),231 out,232 };233 if (route.kind === "blog") {234 out.push({ path: "blog/index.html", bytes: hooks.page(site, leaf) });235 return out;236 }237 const one = only(list, slug!);238 const body = hooks.md ? hooks.md(site, one.body, one.source, out) : one.body;239 const post: Leaf = { ...leaf, kind: "post", name: one.title, description: one.lead, body, slug: one.slug, date: one.date, lead: one.lead, image: one.image, front: one.front };240 out.push({ path: `blog/${one.slug}/index.html`, bytes: hooks.page(site, post) });241 return out;242}