mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 01:12:53 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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 } });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+14
-1
@@ -69,6 +69,12 @@ importers:
|
||||
astro:
|
||||
specifier: ^7.2.9
|
||||
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:
|
||||
|
||||
@@ -5240,6 +5246,11 @@ packages:
|
||||
resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -13581,6 +13592,8 @@ snapshots:
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
|
||||
Reference in New Issue
Block a user