blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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 };