Files
andwati.com/scripts/generate-llms.astro-site.mjs
T
andwati f5cc3a2144 Add the Three.js constellation centerpiece and redesign the homepage
Data model: nodes are real content entries (writings, portfolio,
bookshelf), edges are shared-tag connections — the same relationship
"related posts" already uses, drawn instead of listed. No decorative
or fake data.

- src/lib/constellation-data.ts: builds the node/edge graph from the
  actual collections at build time.
- src/lib/constellation-scene.ts: vanilla Three.js scene (no
  framework) — fibonacci-sphere layout, auto-rotation, pointer
  raycasting with a title tooltip, click-through to the entry's page.
  Honors prefers-reduced-motion (skips rotation and the spring-eased
  entrance, which reuses src/lib/spring.ts) and checks WebGL support,
  falling back to a plain count for browsers/settings that lack it.
- src/components/Constellation.astro: wires it up, serializing the
  graph as inline JSON for the client script to consume.
- Homepage rebuilt around it: hero constellation above a 3-column
  dashboard (latest writings/portfolio/bookshelf), using a new `wide`
  layout variant on Base.astro (dashboard needs more than the 42rem
  article width).

Also: removes "life and beliefs" from all site copy (site.config.ts,
the llms.txt generator + its output) per request — description is
now "Computer science and mathematics."

Verified in-browser: real WebGL scene renders with correctly
positioned/colored nodes and edges, hover shows the right title via
raycasting, layout holds at the new wide container width.
2026-08-31 08:12:35 +03:00

128 lines
4.3 KiB
JavaScript

// Generates apps/site/public/llms.txt for the new Astro site, covering all
// four content types. Run before `astro build` (wired into
// apps/site/package.json's build script) so it's included as a static file.
//
// Site metadata is duplicated from apps/site/src/site.config.ts rather than
// imported — this script runs as plain Node outside Vite/Astro's pipeline,
// and the config is small enough that duplicating it beats adding a
// cross-module-system import.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseToml } from "smol-toml";
const baseUrl = "https://andwati.com";
const siteTitle = "Thoughts and Musings";
const summary =
"Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.";
// Resolved relative to this file, not process.cwd() — this script runs as
// part of apps/site's build script, whose cwd is apps/site, not the repo
// root where content/ lives.
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const contentDir = path.join(root, "content");
const outFile = path.join(root, "apps", "site", "public", "llms.txt");
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
function walk(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
});
}
function slugFor(dir, file) {
const rel = path.relative(dir, file).split(path.sep).join("/");
return rel.endsWith("/index.md")
? rel.slice(0, -"/index.md".length)
: rel.slice(0, -".md".length);
}
function readEntries(typeDir) {
return walk(typeDir)
.map((file) => {
const raw = fs.readFileSync(file, "utf8");
const match = raw.match(FRONTMATTER_RE);
if (!match) return null;
const frontmatter = parseToml(match[1]);
return { slug: slugFor(typeDir, file), frontmatter };
})
.filter(Boolean);
}
const writings = readEntries(path.join(contentDir, "writings"))
.filter((e) => !e.frontmatter.draft)
.sort((a, b) =>
String(b.frontmatter.date).localeCompare(String(a.frontmatter.date)),
);
const portfolio = readEntries(path.join(contentDir, "portfolio")).filter(
(e) => !e.frontmatter.draft,
);
const bookshelf = readEntries(path.join(contentDir, "bookshelf"));
const blogs = readEntries(path.join(contentDir, "blogs"));
function link(title, url, description) {
return `- [${title}](${url})${description ? `: ${description.replace(/\s+/g, " ").trim()}` : ""}`;
}
const lines = [
`# ${siteTitle}`,
"",
`> ${summary}`,
"",
"## Main pages",
"",
`- [Home](${baseUrl}/): Introduction and latest writings.`,
`- [Writings](${baseUrl}/writings/): Long-form essays and technical posts.`,
`- [Portfolio](${baseUrl}/portfolio/): Projects and professional work.`,
`- [Bookshelf](${baseUrl}/bookshelf/): Books and papers read, with notes.`,
`- [Blogs](${baseUrl}/blogs/): Curated external blogs.`,
`- [Tags](${baseUrl}/tags/): Writings grouped by topic.`,
"",
"## Writings",
"",
...writings.map((e) =>
link(
e.frontmatter.title,
`${baseUrl}/writings/${e.slug}/`,
e.frontmatter.description,
),
),
"",
"## Portfolio",
"",
...portfolio.map((e) =>
link(e.frontmatter.title, `${baseUrl}/portfolio/`, e.frontmatter.outcome),
),
"",
"## Bookshelf",
"",
...bookshelf.map((e) =>
link(
e.frontmatter.title,
`${baseUrl}/bookshelf/`,
(e.frontmatter.authors ?? []).join(", "),
),
),
"",
"## Blogs followed",
"",
...blogs.map((e) => link(e.frontmatter.title, e.frontmatter.url)),
"",
"## Feeds and discovery",
"",
`- [RSS feed](${baseUrl}/rss.xml): Full writings feed in RSS format.`,
`- [JSON feed](${baseUrl}/feed.json): Full writings feed in JSON Feed format.`,
`- [Sitemap](${baseUrl}/sitemap-index.xml): Machine-readable index of public pages.`,
"",
];
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, lines.join("\n"));
console.log(
`Generated ${path.relative(root, outFile)} (${writings.length} writings, ${portfolio.length} portfolio, ${bookshelf.length} bookshelf, ${blogs.length} blogs).`,
);