diff --git a/apps/site/package.json b/apps/site/package.json
index 81c7ceb..7b768d8 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -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
diff --git a/apps/site/src/content.config.ts b/apps/site/src/content.config.ts
new file mode 100644
index 0000000..e68c022
--- /dev/null
+++ b/apps/site/src/content.config.ts
@@ -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 };
diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro
new file mode 100644
index 0000000..43883d5
--- /dev/null
+++ b/apps/site/src/layouts/Base.astro
@@ -0,0 +1,62 @@
+---
+interface Props {
+ title: string;
+}
+const { title } = Astro.props;
+---
+
+
+
+
+
+ {title} — andwati.com
+
+
+
+
+
+
+
+
+
diff --git a/apps/site/src/lib/toml-content-loader.ts b/apps/site/src/lib/toml-content-loader.ts
new file mode 100644
index 0000000..ec9b611
--- /dev/null
+++ b/apps/site/src/lib/toml-content-loader.ts
@@ -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;
+ 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 } });
+ }
+ },
+ };
+}
diff --git a/apps/site/src/pages/blogs/index.astro b/apps/site/src/pages/blogs/index.astro
new file mode 100644
index 0000000..c784e7c
--- /dev/null
+++ b/apps/site/src/pages/blogs/index.astro
@@ -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,
+ })),
+);
+---
+
+
+ Blogs I follow
+ {
+ rendered.map(({ entry, Content }) => (
+
+ ))
+ }
+
diff --git a/apps/site/src/pages/bookshelf/index.astro b/apps/site/src/pages/bookshelf/index.astro
new file mode 100644
index 0000000..4780d19
--- /dev/null
+++ b/apps/site/src/pages/bookshelf/index.astro
@@ -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),
+);
+---
+
+
+ Bookshelf
+ {
+ items.map((entry) => (
+
+
+ {entry.data.title} {entry.data.kind}
+
+
{entry.data.authors.join(", ")}
+ {entry.data.rating &&
{"★".repeat(entry.data.rating)}
}
+ {entry.data.status &&
{entry.data.status}
}
+ {entry.data.kind === "book" && entry.data.isbn &&
ISBN {entry.data.isbn}
}
+ {entry.data.kind === "paper" && entry.data.arxiv_id && (
+
+ arXiv:{entry.data.arxiv_id}
+ {entry.data.url && (
+ <>
+ {" "}
+ · source
+ >
+ )}
+
+ )}
+
+ ))
+ }
+
diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro
index 561196b..1ee6174 100644
--- a/apps/site/src/pages/index.astro
+++ b/apps/site/src/pages/index.astro
@@ -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);
+---
----
+
+ Ian Andwati
+ Computer science, mathematics, life, and beliefs.
-
-
-
-
-
-
-
- Astro
-
-
- Astro
-
-
+ Latest writings
+ {
+ writings.length === 0 ? (
+ No writings published yet.
+ ) : (
+ writings.map((entry) => (
+
+
+
{entry.data.date.toDateString()}
+ {entry.data.description &&
{entry.data.description}
}
+
+ ))
+ )
+ }
+
diff --git a/apps/site/src/pages/portfolio/index.astro b/apps/site/src/pages/portfolio/index.astro
new file mode 100644
index 0000000..6daa1ab
--- /dev/null
+++ b/apps/site/src/pages/portfolio/index.astro
@@ -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),
+);
+---
+
+
+ Portfolio
+ {
+ projects.map((entry) => (
+
+
{entry.data.title}
+
{entry.data.role}
+ {entry.data.description &&
{entry.data.description}
}
+ {entry.data.outcome &&
{entry.data.outcome}
}
+ {entry.data.links.map((link) => (
+
{link.label}
+ ))}
+
+ ))
+ }
+
diff --git a/apps/site/src/pages/writings/[...slug].astro b/apps/site/src/pages/writings/[...slug].astro
new file mode 100644
index 0000000..d40da12
--- /dev/null
+++ b/apps/site/src/pages/writings/[...slug].astro
@@ -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);
+---
+
+
+ {entry.data.title}
+
+ {entry.data.date.toDateString()}
+ {entry.data.updated && <> · updated {entry.data.updated.toDateString()}>}
+
+ {entry.data.taxonomies?.tags.map((tag) => #{tag})}
+
+
+
+
diff --git a/apps/site/src/pages/writings/index.astro b/apps/site/src/pages/writings/index.astro
new file mode 100644
index 0000000..e189b99
--- /dev/null
+++ b/apps/site/src/pages/writings/index.astro
@@ -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());
+---
+
+
+ Writings
+ {
+ writings.map((entry) => (
+
+
+
{entry.data.date.toDateString()}
+ {entry.data.description &&
{entry.data.description}
}
+ {entry.data.taxonomies?.tags.map((tag) =>
#{tag})}
+
+ ))
+ }
+
diff --git a/content/portfolio/example-project.md b/content/portfolio/example-project.md
index 087eeae..7f8e5e7 100644
--- a/content/portfolio/example-project.md
+++ b/content/portfolio/example-project.md
@@ -4,7 +4,7 @@ description = "Placeholder portfolio entry establishing the schema."
role = "Author"
date_start = 2026-01-01
outcome = "Established the portfolio content schema."
-draft = true
+draft = false
[[links]]
label = "Source"
url = "https://github.com/andwati"
diff --git a/content/writings/example-first-post/index.md b/content/writings/example-first-post/index.md
index 80922b8..81c5549 100644
--- a/content/writings/example-first-post/index.md
+++ b/content/writings/example-first-post/index.md
@@ -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."
author = "andwati"
date = 2026-08-30
-draft = true
+draft = false
[taxonomies]
tags = ["meta"]
[extra]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 745bdf4..c7a266a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -69,6 +69,12 @@ importers:
astro:
specifier: ^7.2.9
version: 7.2.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@20.19.43)(terser@5.51.2)(yaml@2.9.0)
+ marked:
+ specifier: ^18.0.11
+ version: 18.0.11
+ smol-toml:
+ specifier: ^1.8.0
+ version: 1.8.0
packages:
@@ -5240,6 +5246,11 @@ packages:
resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==}
hasBin: true
+ marked@18.0.11:
+ resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
match-sorter@6.3.4:
resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==}
@@ -13192,7 +13203,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
- semver: 7.7.4
+ semver: 7.8.5
jszip@3.10.1:
dependencies:
@@ -13581,6 +13592,8 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
+ marked@18.0.11: {}
+
match-sorter@6.3.4:
dependencies:
'@babel/runtime': 7.29.7