# Architecture Overview Amytis is a static-export-first Next.js 16 App Router project for Markdown/MDX publishing across posts, series, books, flows, and notes, with optional series-scoped rST support for legacy content. ## Core Stack - Framework: Next.js 16.2.1 + React 19 - Runtime/tooling: Bun - Styling: Tailwind CSS v4 + CSS variables + `next-themes` - Content parsing: `gray-matter` + Zod validation in the `src/lib/content/` data layer - rST rendering: Python `docutils` bridge in `scripts/render-rst.py` plus normalization in `src/lib/rst-renderer.ts` - Search: Pagefind (`/pagefind/pagefind.js` loaded at runtime) - Tests: Bun test suites in `src/` and `tests/` ## Content Model - `content/posts/`: standalone posts (`.md/.mdx`) and folder posts (`index.mdx`) - `content/series//`: series metadata (`index.mdx` / `index.md` / `README.mdx` / `README.md` / `index.rst` / `README.rst`) + series posts in the same format - `content/books//`: book metadata + chapter files - `content/flows/YYYY/MM/DD.(md|mdx)`: daily flow entries - `content/notes/`: evergreen notes - `content/*.mdx`: static pages (about, links, subscribe, privacy, etc.) ## Runtime Data Flow 1. Source files are read from disk by the `src/lib/content/` data layer (all reads funnel through `content/io.ts`). 2. Frontmatter is parsed and validated (invalid frontmatter throws at build time). 3. Draft/future filtering and sorting are applied (based on `site.config.ts`). 4. Route files consume typed helpers (`getAllPosts`, `getBookData`, `getAllFlows`, `getAllNotes`, etc.). 5. `generateStaticParams()` precomputes dynamic routes for static export. 6. Series content format is inferred from the series index file; ambiguous or mixed-format series fail fast during content loading. 7. When `docutils` is available, rST files are rendered to HTML through the Python bridge; if the Python runtime is unavailable, Amytis falls back to the lightweight built-in rST compatibility path. ## Route Map (App Router) ```text src/app/ page.tsx # Homepage page/[page]/page.tsx # Homepage pagination layout.tsx # Root layout/providers posts/page.tsx # Posts index posts/page/[page]/page.tsx # Posts pagination posts/[slug]/page.tsx # Canonical post route series/page.tsx # Series index series/[slug]/page.tsx # Series detail series/[slug]/page/[page]/page.tsx books/page.tsx # Books index books/[slug]/page.tsx # Book landing books/[slug]/[...chapter]/page.tsx # Book chapter (catch-all: nested chapter ids) flows/page.tsx # Flow index (calendar sidebar + full-content card feed) flows/page/[page]/page.tsx # Flow pagination flows/[year]/page.tsx flows/[year]/[month]/page.tsx flows/[year]/[month]/[day]/page.tsx notes/page.tsx # Notes index notes/page/[page]/page.tsx notes/[slug]/page.tsx tags/page.tsx tags/[tag]/page.tsx authors/[author]/page.tsx archive/page.tsx graph/page.tsx feed.xml/route.ts # Main curated RSS feed feed.atom/route.ts # Main curated Atom feed all.xml/route.ts # Firehose RSS feed all.atom/route.ts # Firehose Atom feed posts/feed.xml/route.ts # Posts-only RSS feed posts/feed.atom/route.ts # Posts-only Atom feed flows/feed.xml/route.ts # Flows-only RSS feed flows/feed.atom/route.ts # Flows-only Atom feed search.json/route.ts sitemap.ts [slug]/page.tsx # Static pages + custom series listing path [slug]/page/[page]/page.tsx # Custom series listing pagination [slug]/[postSlug]/page.tsx # Custom post basePath/series post path ``` ## URL Routing Rules - `next.config.ts` sets `output: "export"` and `trailingSlash: true`. - Series format is inferred from the index file: - Markdown series: `index.md`, `index.mdx`, `README.md`, or `README.mdx` - rST series: `index.rst` or `README.rst` - A series may not mix Markdown and rST content files; ambiguous or mixed layouts are treated as build errors. - Post URLs use `getPostUrl()` in `src/lib/urls.ts`: - Default: `//` (basePath defaults to `posts`) - Series auto path: `//` when `series.autoPaths` is enabled - Series override: `//` - Legacy aliases declared in frontmatter `redirectFrom` are emitted as static redirect pages, so old URLs can continue resolving after a rename or path migration. - Dynamic route handlers validate whether a request is canonical or legacy, then either render the content or return `RedirectPage`. - Dynamic route params should return raw segment values from `generateStaticParams()` (do not pre-encode values). - Links should always target concrete paths, not route placeholders such as `/posts/[slug]`. - When moving series posts off the default posts path, `scripts/add-series-redirects.ts` updates frontmatter `redirectFrom` entries so static redirect pages can be generated. ## Key Components Layout & navigation: - `Navbar` with `NavDropdown` / `NavAccordion` — the desktop dropdowns and mobile accordions follow the WAI-ARIA disclosure pattern (click-to-toggle with `aria-expanded`/`aria-controls`, Escape restores focus; hover stays as a mouse convenience) - `Footer`, `Hero` (configurable homepage hero) — server components; their language-reactive strings render through the `T` / `TLocale` / `TLabel` client leaves in `src/components/T.tsx`. Use those leaves instead of adding `'use client'` to a presentational component just for translations. - `LanguageSwitch` (i18n language selector), `ThemeToggle` (light/dark mode) Content renderers: - `MarkdownRenderer` — MDX with GFM, KaTeX math, build-time syntax highlighting via Shiki, Mermaid - `CodeBlock` (async server component, calls Shiki) and `CodeBlockToolbar` (client: copy + word-wrap toggle), `Mermaid` - `CoverImage` — optimized image component with WebP support Post & series surfaces: - `RenderPostPage` — shared body for the two canonical-post routes (`/posts/[slug]` and prefixed `[slug]/[postSlug]`): owns the JSON-LD script, the simple-layout branch, and the related/adjacent/backlinks assembly. Detail-route metadata goes through `buildArticleMetadata` in `src/lib/metadata.ts`. - `PostLayout` / `SimpleLayout` — post page layouts with TOC, series sidebar, external links, comments - `PostList` — card-based post listing with thumbnails, metadata, excerpts, tags - `PostCard`, `PostSidebar`, `RelatedPosts`, `ShareBar` - `SeriesCatalog` — timeline-style listing with numbered entries and progress indicator - `SeriesSidebar` — series navigation sidebar with progress bar and color-coded states - `SeriesList` — mobile-optimized series navigation matching sidebar design - `TableOfContents` — sticky TOC with scroll tracking, reading progress, back-to-top - `HorizontalScroll` — scrollable container with navigation arrows for featured content Notes & flows: - `NoteSidebar`, `TagContentTabs` - `FlowCalendarSidebar` — calendar sidebar with date navigation, browse panel, clickable tag filters - `FlowTimelineEntry` — individual flow entry in timeline list - `FlowStream` — month-grouped full-content card feed on the flow index (server) - `FlowIndexClient` — client wrapper for the flow index and year/month archives; tag filtering swaps the server-rendered card feed for a compact filtered timeline Search & discovery: - `Search` — full-text search modal (Cmd/Ctrl+K): combobox ARIA, focus trap/restore, roving-tabindex type tabs, recent searches. The Pagefind loader/debounce/search machinery lives in the `usePagefind` hook; the results listbox in `SearchResults`. - `Pagination`, `KnowledgeGraph` (loaded via `KnowledgeGraphLazy` — `next/dynamic` with `ssr: false` keeps d3 out of the `/graph` initial chunk; reduced-motion and mobile users get the searchable list view) Integrations: - `Comments` — Giscus or Disqus (theme-aware) - `Analytics` — Umami, Plausible, or Google Analytics ## Data Layer (`src/lib/content/`) One module per concern; the dependency direction is acyclic and enforced by `src/lib/content/dependency-graph.test.ts`: ```text types → io/cache → series-metadata → parse → posts → series → {related, discovery} ↘ books / flows / notes (leaf domains) ``` - `types.ts` — `PostData`, `Heading`, `CollectionContext`, … Zero runtime imports, so client components can type-import without dragging `fs` into the bundle. - `io.ts` — content directory constants, filename conventions, and `readUtf8File` (the ONLY `fs.readFileSync` in the layer; carries the `turbopackIgnore` annotation, enforced by a guard test in `io.test.ts`). - `cache.ts` — `createMemo` / `createKeyedMemo` (env-keyed, cache in dev too) and `createProdMemo` (prod-only; dev recomputes for HMR freshness). The two flavors are deliberate — do not merge them. - `series-metadata.ts` — series index discovery (`resolveSeriesIndexInfo`, `getSeriesContentEntries` with the mixed-format/duplicate-slug throws) plus `getSeriesTitle` / `getSeriesAuthors`, read directly from index files. This is what keeps parse↔series acyclic: nothing here imports `parse.ts`. - `schema.ts` — shared frontmatter fields (`dateField`, `draftField`, `tagsField`) and `invalidFrontmatterError` (all invalid-frontmatter throws embed the Zod field details in the Error message, not just stdout). - `cover-image.ts` — `normalizeCoverImage`, the single relative-cover-image → public-path normalization used by `parse.ts` and `books.ts` (leaf module, keeps the graph acyclic). - `parse.ts` — `PostSchema` and the Markdown/rST file parsers, including the Python-renderer availability singleton and its test hooks (they must stay co-located). - `posts.ts` — `getAllPosts`, `getListingPosts`, `getPostBySlug`, pages (`getAllPages`, `getPageBySlug` with locale variants), `getFeaturedPosts`, `getPostsByTag`. - `authors.ts` — `getAuthorSlug`, `getAllAuthors`, `getPostsByAuthor`, `resolveAuthorParam`. - `series.ts` — `getSeriesData`, `getSeriesPosts`, `getAllSeries`, `getFeaturedSeries`, `getSeriesLatestPostDate`, `resolveSeriesAuthors`, and the collection queries (`getCollectionPosts`, `getCollectionsForPost`). Collections live here, not in their own module: a collection IS a series folder (`type: collection`), and splitting them would create an import cycle. - `books.ts` / `flows.ts` / `notes.ts` — self-contained leaf domains (schema + discovery + queries each). - `related.ts` — `getRelatedPosts`, `getAdjacentPosts` (series-aware adjacency). - `discovery.ts` — cross-content aggregates: `getAllTags`, `buildSlugRegistry` (wikilink targets), `getBacklinks`. - Text metrics: `src/lib/text-metrics.ts` owns reading-time, word-count, excerpt, and heading extraction for **all** formats. The Markdown pipeline, the JS rST fallback (`rst.ts`), and the Python rST renderer (`rst-renderer.ts`, via the `…FromText` plain-text variants) share its tokenizer and pacing constants, so the metrics can never disagree across pipelines. ## Code Block Highlighting - Highlighter: **Shiki** (build-time, dual `github-light` / `github-dark` theme via CSS variables). See `docs/CODE-BLOCKS.md` for author-facing fence/directive metadata. - Singleton lives at `src/lib/shiki.ts` (`getHighlighter()`, cached on `globalThis` for HMR). It exposes `highlightToHast(code, language, opts)` and `parseFenceMeta(meta)`. - Transformers: `transformerMetaHighlight` from `@shikijs/transformers` plus three custom transformers in `src/lib/shiki.ts` for the line-numbers data attribute, the title data attribute, and raw-`diff` line backgrounds. - MDX/Markdown path: `MarkdownRenderer.tsx` → `rehype-fence-meta` (copies `node.data.meta` to a real `data-meta` HTML attribute so it survives `react-markdown`'s prop filtering and the `rehypeRaw` round-trip) → `CodeBlock` (async server component) → Shiki → inline HTML. Mermaid blocks are short-circuited before `CodeBlock` is reached. - rST path: `scripts/render-rst.py` rewrites every `literal_block` into a `
` marker carrying option attributes (`data-language`, `data-line-numbers`, `data-highlight-lines`, `data-title`); `src/lib/shiki-rst.ts` walks the rendered HTML inside `RstRenderer` (async server component) and replaces each marker with Shiki output. The fallback rST parser routes through `rstToMarkdown` and lands in the MDX path — single highlighter, single source of truth.
- Cache: `RST_RENDERER_DISK_CACHE_VERSION` in `src/lib/rst-renderer.ts` must be bumped whenever the docutils output shape or Shiki theme changes, otherwise stale cached entries in `.cache/rst-renderer/` keep rendering with the old markup.

## rST Notes

- Full-fidelity rST rendering depends on a Python environment with `docutils` (and ideally `pygments`) available.
- `src/lib/rst-renderer.ts` uses `AMYTIS_RST_PYTHON` when set; otherwise it falls back to `python3`.
- Docinfo metadata normalization is shared: both the Python renderer and the JS fallback parser route every `:Field:` through `src/lib/rst-metadata.ts` (one field switch, one set of validators, one `RstParseError`), so the two paths cannot drift on metadata. Heading extraction still differs (docutils AST vs regex over the Markdown conversion) — pinned by `tests/integration/rst-parity.test.ts`.
- Top-of-document docinfo is parsed into Amytis metadata, but it is stripped from rendered article HTML so blog-style posts do not show duplicate author/version blocks above the content.
- Supported legacy roles are normalized or degraded intentionally:
  - `:doc:` resolves to local site URLs when the target exists in the imported content tree
  - `:ref:` / `:numref:` prefer local anchors
  - unresolved legacy roles degrade to readable inline HTML instead of docutils system-message blocks

## Build Pipeline

1. `bun scripts/copy-assets.ts`: copy co-located media into `public/`
2. `bun run build:graph`: generate graph data
3. `next build`: static export to `out/`
4. Production only (`bun run build`): `next-image-export-optimizer`
5. Pagefind indexing:
   - Production: `pagefind --site out` (writes to `out/pagefind`)
   - Dev build: `pagefind --site out --output-path public/pagefind`

## Content Frontmatter

Validated by Zod in `src/lib/content/` (PostSchema in `parse.ts`; book/flow/note schemas in their domain modules) — invalid frontmatter throws at build time.

### Posts

```yaml
---
title: "Post Title"
subtitle: "Optional subtitle line"  # Rendered below the title in italic
date: "2026-01-01"
excerpt: "Optional summary (auto-generated if omitted)"
category: "Category Name"
tags: ["tag1", "tag2"]
authors: ["Author Name"]
series: "series-slug"             # Link to a series
draft: true                       # Hidden in production
featured: true                    # Show in featured section
pinned: true                      # Always shown in featured section; hero = most recent pinned
coverImage: "./images/cover.jpg"  # Local path, external URL, or text placeholder
latex: true                       # Enable KaTeX math
toc: false                        # Hide table of contents
layout: "simple"                  # Use simple layout (default: "post")
externalLinks:                    # Links to external discussions
  - name: "Hacker News"
    url: "https://news.ycombinator.com/item?id=12345"
  - name: "V2EX"
    url: "https://v2ex.com/t/123456"
redirectFrom:                     # Old URLs to redirect to this post (prefix changes only)
  - /posts/my-old-slug
  - /old-series/my-old-slug
---
```

### Series (`content/series/[slug]/index.mdx`)

```yaml
---
title: "Series Title"
excerpt: "Series description"
date: "2026-01-01"
coverImage: "./images/cover.jpg"
featured: true               # Show in featured series
draft: true                  # Hidden in production (default: false)
sort: "date-asc"             # 'date-asc' | 'date-desc' | 'manual'
posts: ["post-1", "post-2"]  # Manual post ordering (optional)
---
```

### Books (`content/books/[slug]/index.mdx`)

A book's `chapters:` array accepts three item shapes (mix freely):

```yaml
---
title: "Book Title"
excerpt: "Book description"
date: "2026-01-01"
coverImage: "text:DG"             # Image path or text placeholder
featured: true
draft: false
authors: ["Author Name"]
chapters:
  # 1. Bare chapter ref — top-level chapter with no grouping
  - title: "Standalone Chapter"
    id: "standalone"

  # 2. Legacy "part" — single-level grouping
  - part: "Part I: Getting Started"
    chapters:
      - title: "Chapter Title"
        id: "chapter-file"        # Maps to chapter-file.mdx or chapter-file/index.mdx

  # 3. "section" — recursive grouping with arbitrary nesting depth (≥ 2 layers)
  - section: "机器学习数学基础"
    collapsible: true             # Optional UI hint for the sidebar
    items:
      - section: "线性代数"
        items:
          - title: "引言:机器学习的语言"
            id: "maths/linear/introduction"   # Slash-separated id → nested folder on disk
          - title: "向量基础"
            id: "maths/linear/vectors"
      - section: "微积分"
        items:
          - title: "引言:变化与累积"
            id: "maths/calculus/introduction"
---
```

Chapter `id` values may contain `/` to map to nested folders. For id `maths/linear/introduction`,
the loader resolves to the first existing file under `/maths/linear/introduction.{md,mdx}`
or `/maths/linear/introduction/index.{md,mdx}`. Path traversal (`..`) is rejected.

Per the strict-build invariant, `getBookData` throws if any chapter id in the TOC has no
matching file on disk — silent skips are not allowed.

#### Book-level `latex: true`

Book frontmatter accepts an optional `latex: true` flag that enables KaTeX rendering for
every chapter in the book without having to annotate each chapter file. Chapter-level
`latex: true` still works and takes precedence. Math-heavy books (e.g. ML textbooks) should
set the book-level flag rather than copy it onto every chapter.

#### Book-level `showChapterExcerpt`

Book frontmatter accepts an optional `showChapterExcerpt` flag (default `false`)
controlling whether the chapter-page header renders the chapter's `excerpt` underneath
the title. The default suppresses it because the common case is a chapter that opens
with its own lede paragraph, and an excerpt line above it just duplicates that text.
Set it to `true` on books where the excerpt is a distinct subtitle the author actually
wants the reader to see. The excerpt is still used in metadata (OpenGraph, JSON-LD,
search) regardless of this flag.

#### Book-specific markdown extensions

When `MarkdownRenderer` renders a book chapter (i.e. `bookContext` prop is set, which
happens automatically inside `BookLayout`), two extra plugins fire:

- **`remark-vuepress-containers`** — converts VuePress fenced containers
  (`:::note`, `:::tip`, `:::important`, `:::warning`, `:::danger`, `:::info`) into the
  same `` hast element that `remark-github-alerts` produces. Custom titles
  (`:::tip 智慧的疆界`) are preserved via `data-alert-title`. A small string-level
  preprocessor (`normalizeVuepressContainerSyntax`) normalizes `::: name [label]` →
  `:::name[label]` so `remark-directive` (which only parses the space-less form) sees the
  containers.
- **`remark-book-chapter-links`** — rewrites relative `.md` / `.mdx` links to other
  chapters into canonical `/books//[#fragment]` URLs. Resolution uses
  the chapter's `sourcePath` (exposed by `getBookChapter`). Broken links (target chapter
  id not in the TOC, or target outside the book directory) throw at build time.

Mermaid diagrams in book chapters already work via the existing `Mermaid` component (any
\`\`\`mermaid fenced block, with or without a `compact` modifier after the language tag).

#### Immersive reading mode

Book chapters AND series posts support an "immersive reading" mode: a
fullscreen overlay with a top bar, a content-type-specific sidebar on the
left, and the article in a centred scrollable column. Entered two ways —
the toggle button in the article header (chapter header for books, post
header for series), or the secondary "Immersive reading" CTA on the book
index (`/books/`) or series index (`/series/`), which links to
the first chapter/post with `?immersive=1` appended.

**Generic reader, content-type-specific shells.** The overlay
(`src/components/ImmersiveReader.tsx`) and top bar
(`src/components/ImmersiveReaderTopBar.tsx`) are content-type-agnostic —
both take `rootHref` / `rootTitle` / `currentTitle` props plus a pre-rendered
`sidebar` ReactNode. Two shells consume the reader:

- `src/components/BookReadingShell.tsx` for chapters — passes
  `` as the sidebar.
- `src/components/PostReadingShell.tsx` for series posts — passes
  ``. The toggle is gated on `post.series`;
  non-series posts don't see a useless button.

**Layout boundaries.** The provider needs to survive client-side navigation
between sibling items within a content unit. Three layout files mount it:

- `src/app/books/[slug]/layout.tsx` — books reader (chapter-to-chapter).
- `src/app/[slug]/layout.tsx` — series posts on autoPaths URLs
  (`//`), which is the default.
- `src/app/posts/layout.tsx` — series posts on default-path URLs
  (`/posts/`), for sites with `series.autoPaths: false`.

Each layout file is identical: wraps `{children}` in
`` plus a ``-isolated
``. The three layouts each mount independent
provider instances — `enabled` state doesn't bleed between content types —
but localStorage prefs are shared (same `amytis-reader-prefs` key), so a
reader's font/theme/width choices carry across books and series.

**State + persistence.** `src/components/ImmersiveReadingProvider.tsx` holds
the context. Preferences (`fontSize`, `readingTheme`, `columnWidth`,
`sidebarOpen`) persist to `localStorage` under `amytis-reader-prefs` via the
helpers in `src/lib/immersive-reading-prefs.ts`; the read path is per-key
defensive so schema drift or hand-edited values fall back to their default
without discarding the whole blob. `enabled` and `prefsPanelOpen` are
deliberately **not** persisted — entering the reader is a per-visit intent,
not a preference.

**`?immersive=1` URL flag.** `src/components/ImmersiveReadingFlagHandler.tsx`
sits as a sibling of `{children}` inside each layout (wrapped in its own
``), reads the query param via `useSearchParams`, calls
`provider.enter()`, then strips the flag via `router.replace`. The Suspense
boundary is load-bearing — `useSearchParams` triggers a static-export bailout,
so wrapping the provider instead would drag the chapter/post page out of
static prerender.

**Overlay anatomy** (`ImmersiveReader.tsx`, `position: fixed inset-0 z-40`):

- `ImmersiveReaderTopBar` — sidebar toggle, breadcrumb (book/chapter or
  series/post), `Aa` button, exit (✕). The header is `relative z-30` so its
  `backdrop-blur-md` stacking context paints above article-area code blocks.
- `ImmersiveReadingPrefsPopover` — anchored under the `Aa` button. Four
  control groups with visual previews: font size (4 sizes, `A` letters at the
  actual size), reading theme (Auto / Light / Sepia / Dark colour swatches —
  Auto reads as a split light/dark gradient), column width (Narrow / Medium /
  Wide / Full as stacked line-icons), and a "Reset to defaults" link at the
  bottom (one-click, no confirmation). Dismisses on outside `pointerdown` or
  ESC; ESC with the popover closed exits the reader.
- Sidebar in `mode="fill"` — either `BookSidebar` or `SeriesList`, without
  their page-mode positioning classes. Auto-collapsed below `lg`
  (one-directional: never auto-opens on resize to wide).
- Main scroll area — article centred at the column width the user picked
  (`max-w-2xl` to `max-w-none`).

**CSS scoping.** `html[data-immersive]` hides site chrome via the three stable
hooks `data-site-nav` / `data-site-footer` / `data-reading-progress` —
defense-in-depth (the fixed overlay covers them anyway). Reading-theme
overrides are scoped to `[data-reader-overlay]` so they don't leak outside the
reader; when `readingTheme === 'dark'` the overlay also gets Tailwind's `.dark`
class so `dark:prose-invert` fires regardless of the underlying site theme.
Shiki code blocks deliberately keep their normal theme.

## Configuration Reference (`site.config.ts`)

| Field | Notes |
| --- | --- |
| `nav` | Navigation links with weights |
| `social` | GitHub, Twitter, email links for the footer |
| `series.navbar` | Series slugs to show in the navbar dropdown |
| `series.customPaths` | Per-series URL prefix, e.g. `{ 'weeklies': 'weeklies' }` → `/weeklies/[slug]` |
| `pagination.posts`, `pagination.series` | Items per page |
| `themeColor` | `'default' \| 'blue' \| 'rose' \| 'amber'` |
| `hero` | Homepage hero title and subtitle |
| `i18n` | Default locale and supported locales |
| `featured.series` | Scrollable series: `scrollThreshold` (default 2), `maxItems` (default 6) |
| `featured.stories` | Scrollable stories: `scrollThreshold` (default 1), `maxItems` (default 5) |
| `analytics.providers` | Enabled providers, e.g. `['umami', 'google']`; `[]` disables |
| `comments.provider` | `'giscus' \| 'disqus' \| null` |
| `feed.format` | `'rss' \| 'atom' \| 'both'` |
| `feed.content` | `'full' \| 'excerpt'` |
| `feed.maxItems` | Max feed items (`0` = unlimited) |
| `footer.bottomLinks` | Custom footer links (ICP, cookie policy); `text` accepts plain string or `{ en, zh }` |
| `posts.basePath` | URL prefix for all posts (default `'posts'`) |
| `posts.authors.default` | Fallback authors when a post has none in frontmatter |
| `posts.authors.showInHeader` | Show author byline below post title (default `true`) |
| `posts.authors.showAuthorCard` | Show author card at end of post (default `true`) |
| `posts.excludeFromListing` | Series slugs whose posts are hidden from `/posts` listings |
| `authors` | Per-author profiles: `bio`, `avatar`, `social[]` |

### Config sync

`site.config.ts` (this repo, i18n object form) and `site.config.example.ts`
(shipped via `create-amytis`, plain strings, single-locale, optional features
default disabled) must stay in sync. Any schema change to one must be mirrored
in the other. Locale-aware fields use `{ en, zh }` in `site.config.ts` and
plain strings in the example.

### Config validation

`src/lib/config-schema.ts` holds a Zod `SiteConfigSchema` that validates
`site.config.ts` at module load — the root layout imports `siteConfig` from
there (not from `site.config` directly), so a malformed config fails the build
with a readable `[amytis]` message instead of surfacing as a cryptic runtime
error in a route (strict-build invariant). The schema accepts both shipped
shapes: every locale-aware field is `string | Record`, so the
single-locale example and the bilingual repo config both pass. Objects use
Zod's default semantics (unknown keys ignored), so adding a config field never
breaks validation — but a renamed/mistyped *required* key is still caught. An
integration test (`tests/integration/config-schema.test.ts`) parses both
configs as a drift guard. Mirror any schema change here when you change either
config file.