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" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"astro": "^7.2.9" "astro": "^7.2.9",
"marked": "^18.0.11",
"smol-toml": "^1.8.0"
}, },
"allowScripts": { "allowScripts": {
"esbuild": true "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"> <h2>Latest writings</h2>
<head> {
<meta charset="utf-8" /> writings.length === 0 ? (
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <p class="meta">No writings published yet.</p>
<link rel="icon" href="/favicon.ico" /> ) : (
<meta name="viewport" content="width=device-width" /> writings.map((entry) => (
<meta name="generator" content={Astro.generator} /> <div class="card">
<title>Astro</title> <h3><a href={`/writings/${entry.id}/`}>{entry.data.title}</a></h3>
</head> <p class="meta">{entry.data.date.toDateString()}</p>
<body> {entry.data.description && <p>{entry.data.description}</p>}
<h1>Astro</h1> </div>
</body> ))
</html> )
}
</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>
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Placeholder portfolio entry establishing the schema."
role = "Author" role = "Author"
date_start = 2026-01-01 date_start = 2026-01-01
outcome = "Established the portfolio content schema." outcome = "Established the portfolio content schema."
draft = true draft = false
[[links]] [[links]]
label = "Source" label = "Source"
url = "https://github.com/andwati" url = "https://github.com/andwati"
+1 -1
View File
@@ -3,7 +3,7 @@ title = "Why This Site Exists"
description = "A short note on what this new site is, and why it's more than a tech blog." description = "A short note on what this new site is, and why it's more than a tech blog."
author = "andwati" author = "andwati"
date = 2026-08-30 date = 2026-08-30
draft = true draft = false
[taxonomies] [taxonomies]
tags = ["meta"] tags = ["meta"]
[extra] [extra]
+14 -1
View File
@@ -69,6 +69,12 @@ importers:
astro: astro:
specifier: ^7.2.9 specifier: ^7.2.9
version: 7.2.9(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected]) version: 7.2.9(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected])
marked:
specifier: ^18.0.11
version: 18.0.11
smol-toml:
specifier: ^1.8.0
version: 1.8.0
packages: packages:
@@ -5240,6 +5246,11 @@ packages:
resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==} resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
engines: {node: '>= 20'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==} resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==}
@@ -13192,7 +13203,7 @@ snapshots:
lodash.isstring: 4.0.1 lodash.isstring: 4.0.1
lodash.once: 4.1.1 lodash.once: 4.1.1
ms: 2.1.3 ms: 2.1.3
semver: 7.7.4 semver: 7.8.5
[email protected]: [email protected]:
dependencies: dependencies:
@@ -13581,6 +13592,8 @@ snapshots:
punycode.js: 2.3.1 punycode.js: 2.3.1
uc.micro: 2.1.0 uc.micro: 2.1.0
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7