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 }) => ( +
+

+ {entry.data.title} +

+ +
+ )) + } + 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.title}

+

{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.title}

+

{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