Wire Astro content collections to the shared content/ directory

Adds a custom Astro loader that reads the Zola-style TOML frontmatter
(+++ ... +++) directly, so content/ stays the single source of truth
with no YAML conversion step. Defines writings/portfolio/bookshelf/
blogs collections and basic list/detail pages for each, rendering the
seed entries end-to-end (verified in-browser via `pnpm --filter site
dev`). This is the first runnable MVP: home, writings list + post
detail, portfolio, bookshelf (books and papers), and curated blogs
all render from real content/ files.
This commit is contained in:
2026-08-30 18:14:34 +03:00
parent cae62d4a66
commit 16c8dff5d5
13 changed files with 383 additions and 18 deletions
+3 -1
View File
@@ -12,7 +12,9 @@
"astro": "astro"
},
"dependencies": {
"astro": "^7.2.9"
"astro": "^7.2.9",
"marked": "^18.0.11",
"smol-toml": "^1.8.0"
},
"allowScripts": {
"esbuild": true
+77
View File
@@ -0,0 +1,77 @@
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import { tomlContentLoader } from "./lib/toml-content-loader";
const taxonomies = z
.object({
tags: z.array(z.string()).default([]),
})
.optional();
const writings = defineCollection({
loader: tomlContentLoader("../../content/writings/"),
schema: z.object({
title: z.string(),
description: z.string().optional(),
author: z.string().optional(),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
draft: z.boolean().default(false),
canonical_url: z.string().optional(),
taxonomies,
extra: z
.object({
series: z.string().optional(),
series_index: z.number().optional(),
llms_description: z.string().optional(),
})
.optional(),
}),
});
const portfolio = defineCollection({
loader: tomlContentLoader("../../content/portfolio/"),
schema: z.object({
title: z.string(),
description: z.string().optional(),
role: z.string().optional(),
date_start: z.coerce.date().optional(),
date_end: z.coerce.date().optional(),
outcome: z.string().optional(),
draft: z.boolean().default(false),
links: z
.array(z.object({ label: z.string(), url: z.string() }))
.default([]),
taxonomies,
}),
});
const bookshelf = defineCollection({
loader: tomlContentLoader("../../content/bookshelf/"),
schema: z.object({
title: z.string(),
kind: z.enum(["book", "paper"]),
authors: z.array(z.string()).default([]),
isbn: z.string().optional(),
doi: z.string().optional(),
arxiv_id: z.string().optional(),
url: z.string().optional(),
cover_image: z.string().optional(),
rating: z.number().optional(),
status: z.enum(["reading", "read", "abandoned"]).optional(),
date_started: z.coerce.date().optional(),
date_finished: z.coerce.date().optional(),
taxonomies,
}),
});
const blogs = defineCollection({
loader: tomlContentLoader("../../content/blogs/"),
schema: z.object({
title: z.string(),
url: z.string(),
feed_url: z.string().optional(),
}),
});
export const collections = { writings, portfolio, bookshelf, blogs };
+62
View File
@@ -0,0 +1,62 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — andwati.com</title>
<style>
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, sans-serif;
max-width: 42rem;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
}
nav {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.tag {
display: inline-block;
font-size: 0.8rem;
opacity: 0.7;
margin-right: 0.5rem;
}
.card {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
}
.card h3 {
margin-bottom: 0.25rem;
}
.meta {
font-size: 0.85rem;
opacity: 0.7;
}
</style>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/writings/">Writings</a>
<a href="/portfolio/">Portfolio</a>
<a href="/bookshelf/">Bookshelf</a>
<a href="/blogs/">Blogs</a>
</nav>
<main>
<slot />
</main>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join, relative } from "node:path";
import type { Loader } from "astro/loaders";
import { marked } from "marked";
import { parse as parseToml } from "smol-toml";
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walk(full));
} else if (entry.isFile() && extname(entry.name) === ".md") {
out.push(full);
}
}
return out;
}
/**
* Loads content from Zola-style TOML frontmatter (`+++ ... +++`) Markdown
* files, so the site can read the same content/ directory Strapi's custom
* provider will sync to — no YAML/JSON conversion step.
*/
export function tomlContentLoader(contentDir: string): Loader {
return {
name: "toml-content-loader",
load: async ({ store, config, logger }) => {
store.clear();
const dirUrl = new URL(contentDir, config.root);
let files: string[] = [];
try {
statSync(dirUrl);
files = walk(dirUrl.pathname);
} catch {
logger.warn(`toml-content-loader: no directory at ${dirUrl.pathname}`);
return;
}
for (const file of files) {
const raw = readFileSync(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) {
logger.warn(
`toml-content-loader: skipping ${file}, no +++ frontmatter found`,
);
continue;
}
const [, frontmatter, body] = match;
const data = parseToml(frontmatter) as Record<string, unknown>;
const relPath = relative(dirUrl.pathname, file);
const id = relPath.replace(/\/index\.md$/, "").replace(/\.md$/, "");
const html = await marked.parse(body.trim());
store.set({ id, data, rendered: { html } });
}
},
};
}
+26
View File
@@ -0,0 +1,26 @@
---
import { getCollection, render } from "astro:content";
import Base from "../../layouts/Base.astro";
const blogs = await getCollection("blogs");
const rendered = await Promise.all(
blogs.map(async (entry) => ({
entry,
Content: (await render(entry)).Content,
})),
);
---
<Base title="Blogs I follow">
<h1>Blogs I follow</h1>
{
rendered.map(({ entry, Content }) => (
<div class="card">
<h3>
<a href={entry.data.url}>{entry.data.title}</a>
</h3>
<Content />
</div>
))
}
</Base>
+38
View File
@@ -0,0 +1,38 @@
---
import { getCollection } from "astro:content";
import Base from "../../layouts/Base.astro";
const items = (await getCollection("bookshelf")).sort(
(a, b) =>
(b.data.date_finished?.valueOf() ?? 0) -
(a.data.date_finished?.valueOf() ?? 0),
);
---
<Base title="Bookshelf">
<h1>Bookshelf</h1>
{
items.map((entry) => (
<div class="card">
<h3>
{entry.data.title} <span class="tag">{entry.data.kind}</span>
</h3>
<p class="meta">{entry.data.authors.join(", ")}</p>
{entry.data.rating && <p class="meta">{"★".repeat(entry.data.rating)}</p>}
{entry.data.status && <p class="meta">{entry.data.status}</p>}
{entry.data.kind === "book" && entry.data.isbn && <p class="meta">ISBN {entry.data.isbn}</p>}
{entry.data.kind === "paper" && entry.data.arxiv_id && (
<p class="meta">
arXiv:{entry.data.arxiv_id}
{entry.data.url && (
<>
{" "}
· <a href={entry.data.url}>source</a>
</>
)}
</p>
)}
</div>
))
}
</Base>
+25 -14
View File
@@ -1,17 +1,28 @@
---
import { getCollection } from "astro:content";
import Base from "../layouts/Base.astro";
const writings = (await getCollection("writings", ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 3);
---
---
<Base title="Home">
<h1>Ian Andwati</h1>
<p>Computer science, mathematics, life, and beliefs.</p>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
</body>
</html>
<h2>Latest writings</h2>
{
writings.length === 0 ? (
<p class="meta">No writings published yet.</p>
) : (
writings.map((entry) => (
<div class="card">
<h3><a href={`/writings/${entry.id}/`}>{entry.data.title}</a></h3>
<p class="meta">{entry.data.date.toDateString()}</p>
{entry.data.description && <p>{entry.data.description}</p>}
</div>
))
)
}
</Base>
+28
View File
@@ -0,0 +1,28 @@
---
import { getCollection } from "astro:content";
import Base from "../../layouts/Base.astro";
const projects = (
await getCollection("portfolio", ({ data }) => !data.draft)
).sort(
(a, b) =>
(b.data.date_start?.valueOf() ?? 0) - (a.data.date_start?.valueOf() ?? 0),
);
---
<Base title="Portfolio">
<h1>Portfolio</h1>
{
projects.map((entry) => (
<div class="card">
<h3>{entry.data.title}</h3>
<p class="meta">{entry.data.role}</p>
{entry.data.description && <p>{entry.data.description}</p>}
{entry.data.outcome && <p>{entry.data.outcome}</p>}
{entry.data.links.map((link) => (
<a href={link.url}>{link.label}</a>
))}
</div>
))
}
</Base>
@@ -0,0 +1,27 @@
---
import { getCollection, render } from "astro:content";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const writings = await getCollection("writings", ({ data }) => !data.draft);
return writings.map((entry) => ({
params: { slug: entry.id },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<Base title={entry.data.title}>
<h1>{entry.data.title}</h1>
<p class="meta">
{entry.data.date.toDateString()}
{entry.data.updated && <> · updated {entry.data.updated.toDateString()}</>}
</p>
{entry.data.taxonomies?.tags.map((tag) => <span class="tag">#{tag}</span>)}
<article>
<Content />
</article>
</Base>
+22
View File
@@ -0,0 +1,22 @@
---
import { getCollection } from "astro:content";
import Base from "../../layouts/Base.astro";
const writings = (
await getCollection("writings", ({ data }) => !data.draft)
).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---
<Base title="Writings">
<h1>Writings</h1>
{
writings.map((entry) => (
<div class="card">
<h3><a href={`/writings/${entry.id}/`}>{entry.data.title}</a></h3>
<p class="meta">{entry.data.date.toDateString()}</p>
{entry.data.description && <p>{entry.data.description}</p>}
{entry.data.taxonomies?.tags.map((tag) => <span class="tag">#{tag}</span>)}
</div>
))
}
</Base>