Build homepage from blog content index
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Neeldhara Misra 2026-06-22 13:09:14 +05:30
parent 02e47cf5d0
commit 9074e1884b
4 changed files with 481 additions and 42 deletions

View file

@ -2,10 +2,35 @@
Minimal static homepage for `neeldhara.blog`. Minimal static homepage for `neeldhara.blog`.
The build script fetches the RSS feed from each blog and writes `dist/index.html` The build script reads local Markdown content from `../allblogs/sites/*` when
with the latest two entries per blog. It also copies the bundled Heliotrope that sibling repo exists. In standalone Docker/Dokploy builds, it falls back to
fonts into `dist/webfonts/`. the committed `content/blog-index.json`.
Each blog section shows:
- one optional fixed featured post marked with `main-feature: true`
- the two most recent non-featured posts by `pubDate`
It also copies the bundled Heliotrope fonts into `dist/webfonts/`.
```sh ```sh
npm run build npm run build
``` ```
Refresh the committed content index from the sibling `allblogs` repo with:
```sh
npm run refresh:index
```
To force a build from the committed index, use:
```sh
BLOG_HOME_CONTENT_SOURCE=index npm run build
```
Use this frontmatter key on at most one post per blog:
```yaml
main-feature: true
```

274
content/blog-index.json Normal file
View file

@ -0,0 +1,274 @@
{
"research": [
{
"title": "Intro to Crypto",
"link": "https://research.neeldhara.blog/crypto-intro/",
"date": "2023-12-21",
"featured": true
},
{
"title": "Kidney Exchanges",
"link": "https://research.neeldhara.blog/kidney-exchanges/",
"date": "2022-02-25",
"featured": false
},
{
"title": "Sam I Am",
"link": "https://research.neeldhara.blog/cp/sam-i-am/",
"date": "2021-10-01",
"featured": false
},
{
"title": "How Expensive Can Homework Help Be?",
"link": "https://research.neeldhara.blog/homework-help/",
"date": "2010-05-01",
"featured": false
}
],
"vibes": [
{
"title": "Course Plan Generator",
"link": "https://vibes.neeldhara.blog/course-plan/2024-fall/",
"date": "2023-12-25",
"featured": false
},
{
"title": "Course Plan Generator",
"link": "https://vibes.neeldhara.blog/course-plan/2024-spring/",
"date": "2023-12-25",
"featured": false
},
{
"title": "Building Interactives",
"link": "https://vibes.neeldhara.blog/building-interactives/",
"date": "2023-07-30",
"featured": false
},
{
"title": "Course Plan Generator",
"link": "https://vibes.neeldhara.blog/course-plan/",
"date": "2023-04-25",
"featured": false
},
{
"title": "13 Sheep",
"link": "https://vibes.neeldhara.blog/13sheep/",
"date": "2023-03-26",
"featured": true
}
],
"puzzles": [
{
"title": "Dog Bunny Puzzle",
"link": "https://puzzles.neeldhara.blog/dogs-bunny-puzzle/",
"date": "2022-09-19",
"featured": true
},
{
"title": "Solo Chess",
"link": "https://puzzles.neeldhara.blog/solo-chess/",
"date": "2022-03-24",
"featured": false
},
{
"title": "Two approaches to the 15 puzzle",
"link": "https://puzzles.neeldhara.blog/15-puzzle/",
"date": "2021-10-03",
"featured": false
},
{
"title": "Moving Blocks at CTIS 2021",
"link": "https://puzzles.neeldhara.blog/moving-blocks-ctis/",
"date": "2021-09-21",
"featured": false
}
],
"reflections": [
{
"title": "On Career Choices",
"link": "https://reflections.neeldhara.blog/on-career-choices/",
"date": "2023-05-26",
"featured": true
},
{
"title": "The Only Fair Ranking of IITs",
"link": "https://reflections.neeldhara.blog/iit-rankings/",
"date": "2022-11-24",
"featured": false
},
{
"title": "Exportober 2022",
"link": "https://reflections.neeldhara.blog/exportober/2022-tracker/",
"date": "2022-10-07",
"featured": false
},
{
"title": "SKJ",
"link": "https://reflections.neeldhara.blog/skj/",
"date": "2022-09-23",
"featured": false
},
{
"title": "On Teaching",
"link": "https://reflections.neeldhara.blog/on-teaching/",
"date": "2022-09-05",
"featured": false
},
{
"title": "Eight Self-Sabotaging Behaviors",
"link": "https://reflections.neeldhara.blog/eight-self-sabotaging-behaviors/",
"date": "2022-03-15",
"featured": false
},
{
"title": "Exportober 2021",
"link": "https://reflections.neeldhara.blog/exportober/2021-tracker/",
"date": "2021-09-25",
"featured": false
},
{
"title": "About Exportober",
"link": "https://reflections.neeldhara.blog/exportober/about/",
"date": "2021-09-24",
"featured": false
},
{
"title": "An Invitation to Exportober 2021",
"link": "https://reflections.neeldhara.blog/exportober/2021/",
"date": "2021-09-19",
"featured": false
}
],
"poetry": [
{
"title": "Winning",
"link": "https://poetry.neeldhara.blog/poems/winning/",
"date": "2024-01-13",
"featured": false
},
{
"title": "Poetry",
"link": "https://poetry.neeldhara.blog/poems/poetry/",
"date": "2024-01-01",
"featured": true
},
{
"title": "Letting Go",
"link": "https://poetry.neeldhara.blog/poems/letting-go/",
"date": "2023-05-11",
"featured": false
},
{
"title": "Dad",
"link": "https://poetry.neeldhara.blog/poems/dad/",
"date": "2012-08-27",
"featured": false
},
{
"title": "On the Fence",
"link": "https://poetry.neeldhara.blog/poems/on-the-fence/",
"date": "2012-04-23",
"featured": false
},
{
"title": "Sprinkles of the Sky",
"link": "https://poetry.neeldhara.blog/poems/sprinkles-of-the-sky/",
"date": "2011-01-05",
"featured": false
},
{
"title": "Seek",
"link": "https://poetry.neeldhara.blog/poems/seek/",
"date": "2007-04-03",
"featured": false
},
{
"title": "Bloom",
"link": "https://poetry.neeldhara.blog/poems/bloom/",
"date": "2005-02-03",
"featured": false
}
],
"reviews": [
{
"title": "Exportober 2023",
"link": "https://reviews.neeldhara.blog/2023-exportober-workflows/",
"date": "2023-10-01",
"featured": false
},
{
"title": "Note Taking Resources",
"link": "https://reviews.neeldhara.blog/note-taking-resources/",
"date": "2023-06-02",
"featured": true
},
{
"title": "Letters with Pandoc",
"link": "https://reviews.neeldhara.blog/pandoc-letters/",
"date": "2022-11-27",
"featured": false
},
{
"title": "Women in Mathematics",
"link": "https://reviews.neeldhara.blog/women-in-mathematics/",
"date": "2022-02-21",
"featured": false
},
{
"title": "New Mac",
"link": "https://reviews.neeldhara.blog/new-mac/",
"date": "2021-09-30",
"featured": false
},
{
"title": "Envelope Budgeting with Notion",
"link": "https://reviews.neeldhara.blog/envelope-budgeting-notion/",
"date": "2021-09-18",
"featured": false
},
{
"title": "Actually Building a Website with Notion",
"link": "https://reviews.neeldhara.blog/building-websites-with-notion/",
"date": "2021-09-12",
"featured": false
},
{
"title": "Notion-powered websites",
"link": "https://reviews.neeldhara.blog/notion-powered-websites/",
"date": "2021-09-11",
"featured": false
},
{
"title": "Massren for fast file renaming",
"link": "https://reviews.neeldhara.blog/massren/",
"date": "2020-09-11",
"featured": false
},
{
"title": "Building a first Django App",
"link": "https://reviews.neeldhara.blog/django-app/",
"date": "2018-06-12",
"featured": false
}
],
"art": [
{
"title": "Creating a Gallery of Solved Crosswords",
"link": "https://art.neeldhara.blog/indiamini-crosswords/",
"date": "2024-05-10",
"featured": true
},
{
"title": "External Communications",
"link": "https://art.neeldhara.blog/comms/",
"date": "2022-09-23",
"featured": false
},
{
"title": "On the Communication Complexity of Equality",
"link": "https://art.neeldhara.blog/communication-complexity-equality/",
"date": "2021-10-04",
"featured": false
}
]
}

View file

@ -4,6 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node scripts/build.mjs" "build": "node scripts/build.mjs",
"refresh:index": "node scripts/build.mjs --write-content-index"
} }
} }

View file

@ -1,4 +1,13 @@
import { copyFile, mkdir, writeFile } from "node:fs/promises"; import {
access,
copyFile,
mkdir,
readdir,
readFile,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const blogs = [ const blogs = [
{ {
@ -77,10 +86,13 @@ const socialLinks = [
}, },
]; ];
const rssHostSuffix = process.env.RSS_HOST_SUFFIX ?? "neeldhara.email";
const publicHostSuffix = process.env.PUBLIC_HOST_SUFFIX ?? "neeldhara.blog"; const publicHostSuffix = process.env.PUBLIC_HOST_SUFFIX ?? "neeldhara.blog";
const allblogsRoot = new URL("../../allblogs/", import.meta.url);
const contentIndex = new URL("../content/blog-index.json", import.meta.url);
const outDir = new URL("../dist/", import.meta.url); const outDir = new URL("../dist/", import.meta.url);
const fontDir = new URL("../webfonts/", import.meta.url); const fontDir = new URL("../webfonts/", import.meta.url);
const shouldWriteContentIndex = process.argv.includes("--write-content-index");
const contentSource = process.env.BLOG_HOME_CONTENT_SOURCE ?? "auto";
const fontFiles = [ const fontFiles = [
"heliotrope_3_regular.woff2", "heliotrope_3_regular.woff2",
"heliotrope_3_italic.woff2", "heliotrope_3_italic.woff2",
@ -88,50 +100,145 @@ const fontFiles = [
"heliotrope_3_bold_italic.woff2", "heliotrope_3_bold_italic.woff2",
]; ];
function decodeXml(value) {
return value
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/>/g, ">")
.replace(/&lt;/g, "<")
.replace(/&amp;/g, "&");
}
function escapeHtml(value) { function escapeHtml(value) {
return value return value
.toString()
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function textFrom(block, tag) { function parseScalar(value) {
const match = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i")); const trimmed = value.trim();
return match ? decodeXml(match[1].trim()) : "";
}
function parseItems(xml) { if (trimmed === "true") return true;
return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/gi)].slice(0, 2).map((match) => { if (trimmed === "false") return false;
const block = match[1];
const pubDate = new Date(textFrom(block, "pubDate"));
return { if (
title: textFrom(block, "title"), (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
link: textFrom(block, "link"), (trimmed.startsWith("'") && trimmed.endsWith("'"))
date: Number.isNaN(pubDate.valueOf()) ? "" : pubDate.toISOString().slice(0, 10), ) {
}; return trimmed.slice(1, -1);
});
}
async function getPosts(slug) {
const url = `https://${slug}.${rssHostSuffix}/rss.xml`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} returned ${response.status}`);
} }
return parseItems(await response.text()); if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed
.slice(1, -1)
.split(",")
.map((item) => parseScalar(item))
.filter(Boolean);
}
return trimmed;
}
function parseFrontmatter(markdown) {
if (!markdown.startsWith("---")) return {};
const end = markdown.indexOf("\n---", 3);
if (end === -1) return {};
const yaml = markdown.slice(3, end).trim();
const data = {};
for (const line of yaml.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!match) continue;
data[match[1]] = parseScalar(match[2]);
}
return data;
}
async function pathExists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function findMarkdownFiles(dir) {
const files = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await findMarkdownFiles(fullPath)));
continue;
}
if (entry.isFile() && /\.(md|mdx)$/i.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
function slugFromFile(filePath, contentDir) {
const relative = path.relative(contentDir, filePath).replaceAll(path.sep, "/");
return relative.replace(/\/index\.mdx?$/i, "").replace(/\.mdx?$/i, "");
}
async function getLocalPosts(blog) {
const contentDir = fileURLToPath(
new URL(`sites/${blog.slug}/src/content/${blog.slug}/`, allblogsRoot),
);
if (!(await pathExists(contentDir))) {
return null;
}
const files = await findMarkdownFiles(contentDir);
const posts = [];
for (const file of files) {
const data = parseFrontmatter(await readFile(file, "utf8"));
const pubDate = new Date(`${data.pubDate}T00:00:00Z`);
if (!data.title || Number.isNaN(pubDate.valueOf())) {
continue;
}
const slug = slugFromFile(file, contentDir);
posts.push({
title: data.title,
link: `https://${blog.slug}.${publicHostSuffix}/${slug}/`,
date: pubDate.toISOString().slice(0, 10),
featured: data["main-feature"] === true || data.mainFeature === true,
});
}
return posts.sort((a, b) => b.date.localeCompare(a.date));
}
async function getIndexedPosts() {
const index = JSON.parse(await readFile(contentIndex, "utf8"));
return blogs.map((blog) => [blog, index[blog.slug] ?? []]);
}
async function getEntries() {
if (contentSource === "index") {
return getIndexedPosts();
}
const localEntries = await Promise.all(
blogs.map(async (blog) => [blog, await getLocalPosts(blog)]),
);
if (localEntries.every(([, posts]) => posts !== null)) {
return localEntries;
}
return getIndexedPosts();
} }
function renderPost(post) { function renderPost(post) {
@ -143,6 +250,12 @@ function renderPost(post) {
return `<li class="post"><span class="date">${escapeHtml(dateLabel)}</span><a href="${escapeHtml(post.link)}">${escapeHtml(post.title)}</a></li>`; return `<li class="post"><span class="date">${escapeHtml(dateLabel)}</span><a href="${escapeHtml(post.link)}">${escapeHtml(post.title)}</a></li>`;
} }
function renderFeaturedPost(post) {
if (!post) return "";
return `<p class="featured-post"><span>Featured</span><a href="${escapeHtml(post.link)}">${escapeHtml(post.title)}</a></p>`;
}
function renderRssIcon(blog) { function renderRssIcon(blog) {
const href = `https://${blog.slug}.${publicHostSuffix}/rss.xml`; const href = `https://${blog.slug}.${publicHostSuffix}/rss.xml`;
@ -177,6 +290,9 @@ function renderSocialLinks() {
} }
function renderBlog(blog, posts) { function renderBlog(blog, posts) {
const featured = posts.find((post) => post.featured);
const latest = posts.filter((post) => post !== featured).slice(0, 2);
return `<details class="blog"> return `<details class="blog">
<summary> <summary>
<span>${escapeHtml(blog.label)}</span> <span>${escapeHtml(blog.label)}</span>
@ -186,15 +302,26 @@ function renderBlog(blog, posts) {
</span> </span>
</summary> </summary>
<p>${escapeHtml(blog.summary)}</p> <p>${escapeHtml(blog.summary)}</p>
${renderFeaturedPost(featured)}
<ul> <ul>
${posts.map(renderPost).join("\n ")} ${latest.map(renderPost).join("\n ")}
</ul> </ul>
</details>`; </details>`;
} }
const entries = await Promise.all( const entries = await getEntries();
blogs.map(async (blog) => [blog, await getPosts(blog.slug)]),
); if (shouldWriteContentIndex) {
await mkdir(new URL("../content/", import.meta.url), { recursive: true });
await writeFile(
contentIndex,
`${JSON.stringify(
Object.fromEntries(entries.map(([blog, posts]) => [blog.slug, posts])),
null,
2,
)}\n`,
);
}
const html = `<!doctype html> const html = `<!doctype html>
<html lang="en"> <html lang="en">
@ -393,6 +520,18 @@ const html = `<!doctype html>
margin-top: 9px; margin-top: 9px;
color: #555; color: #555;
} }
.featured-post {
display: flex;
align-items: baseline;
gap: 12px;
}
.featured-post span {
color: #666;
font-size: 13px;
}
.featured-post a {
font-weight: 700;
}
ul { ul {
display: grid; display: grid;
gap: 7px; gap: 7px;