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"
|
"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
|
||||||
|
|||||||
@@ -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">
|
<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>
|
||||||
|
|||||||
@@ -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"
|
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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Generated
+14
-1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user