This commit is contained in:
parent
02e47cf5d0
commit
9074e1884b
4 changed files with 481 additions and 42 deletions
31
README.md
31
README.md
|
|
@ -2,10 +2,35 @@
|
|||
|
||||
Minimal static homepage for `neeldhara.blog`.
|
||||
|
||||
The build script fetches the RSS feed from each blog and writes `dist/index.html`
|
||||
with the latest two entries per blog. It also copies the bundled Heliotrope
|
||||
fonts into `dist/webfonts/`.
|
||||
The build script reads local Markdown content from `../allblogs/sites/*` when
|
||||
that sibling repo exists. In standalone Docker/Dokploy builds, it falls back to
|
||||
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
|
||||
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
274
content/blog-index.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs"
|
||||
"build": "node scripts/build.mjs",
|
||||
"refresh:index": "node scripts/build.mjs --write-content-index"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
@ -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 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 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 = [
|
||||
"heliotrope_3_regular.woff2",
|
||||
"heliotrope_3_italic.woff2",
|
||||
|
|
@ -88,50 +100,145 @@ const fontFiles = [
|
|||
"heliotrope_3_bold_italic.woff2",
|
||||
];
|
||||
|
||||
function decodeXml(value) {
|
||||
return value
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.toString()
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function textFrom(block, tag) {
|
||||
const match = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"));
|
||||
return match ? decodeXml(match[1].trim()) : "";
|
||||
}
|
||||
function parseScalar(value) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
function parseItems(xml) {
|
||||
return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/gi)].slice(0, 2).map((match) => {
|
||||
const block = match[1];
|
||||
const pubDate = new Date(textFrom(block, "pubDate"));
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
|
||||
return {
|
||||
title: textFrom(block, "title"),
|
||||
link: textFrom(block, "link"),
|
||||
date: Number.isNaN(pubDate.valueOf()) ? "" : pubDate.toISOString().slice(0, 10),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
const href = `https://${blog.slug}.${publicHostSuffix}/rss.xml`;
|
||||
|
||||
|
|
@ -177,6 +290,9 @@ function renderSocialLinks() {
|
|||
}
|
||||
|
||||
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">
|
||||
<summary>
|
||||
<span>${escapeHtml(blog.label)}</span>
|
||||
|
|
@ -186,15 +302,26 @@ function renderBlog(blog, posts) {
|
|||
</span>
|
||||
</summary>
|
||||
<p>${escapeHtml(blog.summary)}</p>
|
||||
${renderFeaturedPost(featured)}
|
||||
<ul>
|
||||
${posts.map(renderPost).join("\n ")}
|
||||
${latest.map(renderPost).join("\n ")}
|
||||
</ul>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
blogs.map(async (blog) => [blog, await getPosts(blog.slug)]),
|
||||
);
|
||||
const entries = await getEntries();
|
||||
|
||||
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>
|
||||
<html lang="en">
|
||||
|
|
@ -393,6 +520,18 @@ const html = `<!doctype html>
|
|||
margin-top: 9px;
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue