src/content.config.ts (6580B)
1 import { defineCollection, z } from 'astro:content';
2 import { glob } from 'astro/loaders';
3
4 /** Lowercase ASCII only, leaving Korean and Cyrillic untouched. */
5 export const asciiLower = (s: string): string =>
6 s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
7
8 /**
9 * YAML parses `2025-12-07` into a Date at midnight UTC. It must be read back in UTC: read
10 * in local time west of UTC, every date falls back a day, which would also move any post
11 * dated January 1 into the previous year's URL.
12 */
13 const dateString = z.union([z.string(), z.date()]).transform((v) =>
14 typeof v === 'string' ? v.slice(0, 10) : v.toISOString().slice(0, 10),
15 );
16
17 /**
18 * YAML reads a key with nothing after it — `deck:` — as null, and null satisfies neither
19 * `.optional()`, which admits undefined and not null, nor `.default([])`, which fires on
20 * undefined only. Left alone, every optional field rejects the blank line while accepting
21 * the key being absent altogether, which is exactly backwards for a site whose posts are
22 * copied from a template of blanks.
23 *
24 * So each optional field is wrapped here, and both spellings of "nothing" — bare null and
25 * the quoted empty string `deck: ""` — are absorbed to undefined before the field's own
26 * schema sees the value. The field schema is still the one that runs, so `type: novel` and
27 * `tags: security` are rejected as loudly as before; only emptiness is reinterpreted.
28 *
29 * This is what content/_template.md stands on: a line the author leaves blank parses to
30 * undefined, and undefined is what every page already tests for before it prints anything.
31 */
32 const blankable = <T extends z.ZodType>(field: T) =>
33 z.preprocess((v) => (v === null || v === '' ? undefined : v), field);
34
35 /** Optional free text. Absent, `deck:` and `deck: ""` all arrive as undefined. */
36 const optText = blankable(z.string().optional());
37
38 const slugOf = ({ entry }: { entry: string }) =>
39 asciiLower(entry.replace(/^.*\//, '').replace(/\.md$/, ''));
40
41 const writing = defineCollection({
42 loader: glob({ base: './content/writing', pattern: '*.md', generateId: slugOf }),
43 schema: z.object({
44 title: z.string(),
45 /** Keeps the original Korean title when a post switches to an English one. */
46 titleKo: optText,
47 date: dateString,
48 /** In an index without images the deck carries the entry. May be empty until backfilled. */
49 deck: optText,
50 /**
51 * Published as data-type on the /writing/ entry, which is what a later split into
52 * sub-views would key on rather than a URL segment. Untyped is the norm.
53 */
54 type: blankable(z.enum(['essay', 'research']).optional()),
55 /** A blank `tags:` and `tags: []` mean the same thing, and neither prints a tag list. */
56 tags: blankable(z.array(z.string()).default([])),
57 /** Blank publishes. Only an explicit `draft: true` withholds a post. */
58 draft: blankable(z.boolean().optional()),
59 }),
60 });
61
62 /**
63 * A project link. Either an absolute http(s) URL or a root-relative path on this site:
64 * the reference's own code page points some entries at GitHub and others at a page it
65 * hosts itself, so both shapes stay open.
66 *
67 * This is the one field on the page whose defect a schema can actually catch. `new URL()`
68 * rejects `htps://…`, a missing slash after the scheme and a host with no dot; what it
69 * cannot see is a well-formed URL pointing at the wrong repository, so the check is a
70 * floor, not a guarantee. Internal paths are required to end in a slash because
71 * astro.config.mjs builds with `trailingSlash: 'always'` — without one the link takes a
72 * redirect, or 404s on a host that does not add it.
73 *
74 * Written as a single `.refine()` with a boolean predicate rather than `superRefine` +
75 * `ctx.addIssue`, whose issue shape differs between Zod majors; this form reads the same
76 * whichever one `astro:content` re-exports.
77 */
78 const isProjectUrl = (v: string): boolean => {
79 if (v.startsWith('/')) return v.endsWith('/');
80 try {
81 const u = new URL(v);
82 return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname.includes('.');
83 } catch {
84 return false;
85 }
86 };
87
88 const projectUrl = z.string().refine(isProjectUrl, {
89 message:
90 'must be an absolute http(s) URL with a dotted hostname, or a path on this site ending in "/"',
91 });
92
93 /**
94 * The Code index is a hand-ranked list, so it carries no date: `order` ascending is the
95 * whole sort, and the author changes the page by changing one number in front matter.
96 * Numbered in tens in content/code so a project can be slotted between two others
97 * without renumbering the tail.
98 *
99 * Deliberately NOT derived from anything: sorting by date would put the list in an order
100 * nobody chose, and sorting by stars would let GitHub reorder the page.
101 */
102 const code = defineCollection({
103 loader: glob({ base: './content/code', pattern: '*.md', generateId: slugOf }),
104 schema: z.object({
105 /**
106 * The link text, and the project's own name rather than its repo slug — `UAF —
107 * Ultimate AP Finder`, not `uaf`. The slug still comes from the filename, so the two
108 * are free to differ.
109 */
110 name: z.string(),
111 url: projectUrl,
112 order: z.number().int(),
113 /** Blankable for the same reason the writing fields are: `draft:` is null, not false. */
114 draft: blankable(z.boolean().optional()),
115 }),
116 /*
117 * No `description` field. The one-sentence description is the markdown BODY of each
118 * file, which is what a markdown file is for: it needs no YAML quoting, so a colon or
119 * an apostrophe in a sentence cannot break the parse, and an author who wants to put a
120 * link or an emphasis inside the sentence later can.
121 *
122 * Also no `language` and no `licence`, though both were to hand. Three reasons, in
123 * order of weight: the reference prints neither and this page is modelled on it; both
124 * are one click away at the other end of the link that is already the entry's headline;
125 * and a field this site cannot keep true should not be printed — `uaf` already
126 * disagrees with itself, carrying no licence in its repository metadata and
127 * `GPL-2.0-or-later` in its README. Stars are out for the same reason twice over: the
128 * reference shows none, and a number that changes without a commit does not belong in
129 * a git-based site's content.
130 */
131 });
132
133 const about = defineCollection({
134 loader: glob({ base: './content', pattern: 'about.md', generateId: () => 'about' }),
135 schema: z.object({ title: z.string(), date: dateString.optional() }),
136 });
137
138 export const collections = { writing, code, about };