summaryrefslogtreecommitdiffstats
path: root/_writing/lib/posts.mjs
blob: 48a6df67f55c4e618dbd453741f98d28fc56b584 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
/* Loading, parsing and ordering of post source files. */

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
import { readingTime, excerpt } from "./markdown.mjs";

export const WRITING_DIR = path.resolve(fileURLToPath(new URL("../", import.meta.url)));
export const ROOT_DIR = path.resolve(WRITING_DIR, "..");
export const POSTS_DIR = path.join(WRITING_DIR, "posts");
export const DRAFTS_DIR = path.join(WRITING_DIR, "drafts");
export const BLOG_OUT_DIR = path.join(ROOT_DIR, "blog");

export const dirFor = (status) => (status === "draft" ? DRAFTS_DIR : POSTS_DIR);

export function ensureDirs() {
  for (const d of [POSTS_DIR, DRAFTS_DIR]) fs.mkdirSync(d, { recursive: true });
}

/* A cover banner sits beside its post as <slug>.cover.<ext>, so it follows the
   post through every move the .md makes: a draft's cover lives in the ignored
   drafts dir and cannot ship early, and publishing carries it into posts/. */
export const COVER_EXTS = ["webp", "jpg", "jpeg", "png", "gif"];

export function findCover(dir, slug) {
  for (const ext of COVER_EXTS) {
    const file = path.join(dir, `${slug}.cover.${ext}`);
    if (fs.existsSync(file)) return { file, ext };
  }
  return null;
}

export function removeCover(dir, slug) {
  let cover;
  while ((cover = findCover(dir, slug))) fs.unlinkSync(cover.file);
}

/* The URL carries a content hash, so replacing a cover never serves the old
   one out of a cache. */
export function coverOf(dir, slug) {
  const cover = findCover(dir, slug);
  if (!cover) return null;
  cover.v = crypto.createHash("sha1").update(fs.readFileSync(cover.file)).digest("hex").slice(0, 8);
  return cover;
}

export const coverUrl = (slug, cover) => (cover ? `/blog/${slug}/cover.${cover.ext}?v=${cover.v}` : null);

export function moveCover(fromDir, fromSlug, toDir, toSlug) {
  const cover = findCover(fromDir, fromSlug);
  if (!cover) return;
  const dest = path.join(toDir, `${toSlug}.cover.${cover.ext}`);
  if (dest === cover.file) return;
  removeCover(toDir, toSlug);
  fs.renameSync(cover.file, dest);
}

// Combining accent marks, built from char codes so the source file stays ASCII.
const COMBINING_MARKS = new RegExp("[\\u0300-\\u036f]", "g");

export function slugify(input) {
  // NFKD splits accents off their base letter and the mark strip removes them,
  // so "Café" slugs to "cafe" rather than "caf-e".
  return (
    String(input)
      .toLowerCase()
      .normalize("NFKD")
      .replace(COMBINING_MARKS, "")
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-+|-+$/g, "")
      .slice(0, 60) || "untitled"
  );
}

/* A small YAML subset: scalars, quoted strings, and inline [a, b] lists. That
   covers every field a post actually uses and avoids a parser dependency. */
export function parseFrontmatter(raw) {
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
  if (!match) return { data: {}, body: raw };

  const data = {};
  for (const line of match[1].split(/\r?\n/)) {
    const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
    if (!kv) continue;
    let value = kv[2].trim();
    if (/^\[.*\]$/.test(value)) {
      value = value
        .slice(1, -1)
        .split(",")
        .map((v) => v.trim().replace(/^["']|["']$/g, ""))
        .filter(Boolean);
    } else {
      value = value.replace(/^["']|["']$/g, "");
      if (value === "true") value = true;
      else if (value === "false") value = false;
    }
    data[kv[1]] = value;
  }
  return { data, body: raw.slice(match[0].length) };
}

export function serialize({ title, date, description = "", tags = [], body = "" }) {
  const lines = ["---", `title: ${JSON.stringify(String(title || "Untitled"))}`, `date: ${date}`];
  if (description) lines.push(`description: ${JSON.stringify(description)}`);
  if (tags && tags.length) lines.push(`tags: [${tags.join(", ")}]`);
  lines.push("---", "");
  // Trailing whitespace is normalized away so saving an unchanged post writes
  // byte-identical output — republish must not manufacture a diff.
  return lines.join("\n") + String(body).replace(/^\n+/, "").replace(/\s+$/, "") + "\n";
}

const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
const MONTHS_LONG = ["January", "February", "March", "April", "May", "June",
  "July", "August", "September", "October", "November", "December"];

/* Dates are parsed as plain calendar values. Going through Date() would shift
   them by a day for anyone west of UTC, which is exactly where these get read. */
function splitDate(iso) {
  const m = String(iso || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
  if (!m) return null;
  return { year: +m[1], month: +m[2], day: +m[3] };
}

export function shortDate(iso) {
  const d = splitDate(iso);
  return d ? `${MONTHS[d.month - 1]} ${String(d.day).padStart(2, "0")}` : "";
}

export function longDate(iso) {
  const d = splitDate(iso);
  return d ? `${MONTHS_LONG[d.month - 1]} ${d.day}, ${d.year}` : "";
}

export function yearOf(iso) {
  const d = splitDate(iso);
  return d ? d.year : 0;
}

export function todayISO() {
  const now = new Date();
  const pad = (n) => String(n).padStart(2, "0");
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}

function loadFile(file, status) {
  const raw = fs.readFileSync(file, "utf8");
  const { data, body } = parseFrontmatter(raw);
  const slug = path.basename(file, ".md");
  return {
    slug,
    status,
    file,
    cover: coverOf(path.dirname(file), slug),
    title: data.title || slug,
    date: data.date || "",
    description: data.description || excerpt(body),
    tags: Array.isArray(data.tags) ? data.tags : data.tags ? [data.tags] : [],
    minutes: readingTime(body),
    body,
    raw,
  };
}

export function loadPosts({ includeDrafts = false } = {}) {
  ensureDirs();
  const read = (dir, status) =>
    fs
      .readdirSync(dir)
      .filter((f) => f.endsWith(".md"))
      .map((f) => loadFile(path.join(dir, f), status));

  const posts = read(POSTS_DIR, "published");
  if (includeDrafts) posts.push(...read(DRAFTS_DIR, "draft"));

  // Newest first, with slug as a stable tiebreaker for same-day posts.
  return posts.sort((a, b) => b.date.localeCompare(a.date) || a.slug.localeCompare(b.slug));
}

export function findPost(slug, { includeDrafts = true } = {}) {
  return loadPosts({ includeDrafts }).find((p) => p.slug === slug) || null;
}