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}