609 lines
17 KiB
JavaScript
609 lines
17 KiB
JavaScript
import {
|
|
access,
|
|
copyFile,
|
|
mkdir,
|
|
readdir,
|
|
readFile,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const blogs = [
|
|
{
|
|
slug: "research",
|
|
label: "Research",
|
|
summary: "Notes from papers, algorithms, computational social choice, and mini-surveys of themes I am learning about.",
|
|
},
|
|
{
|
|
slug: "vibes",
|
|
label: "Vibes",
|
|
summary: "Loose experiments, AI conversations, and work-in-progress thoughts that do not need a formal frame.",
|
|
},
|
|
{
|
|
slug: "puzzles",
|
|
label: "Puzzles",
|
|
summary: "Problems, teaching prompts, contest-style curiosities, and small mathematical rabbit holes.",
|
|
},
|
|
{
|
|
slug: "reflections",
|
|
label: "Reflections",
|
|
summary: "Personal notes on teaching, academia, writing, attention, and the rhythms of intellectual work.",
|
|
},
|
|
{
|
|
slug: "poetry",
|
|
label: "Poetry",
|
|
summary: "Poems, tiny forms, and playful writing around mathematics, code, and ordinary life.",
|
|
},
|
|
{
|
|
slug: "reviews",
|
|
label: "Reviews",
|
|
summary: "Notes on tools, workflows, books, hardware, and objects that shape everyday work.",
|
|
},
|
|
{
|
|
slug: "art",
|
|
label: "Art",
|
|
summary: "Algorithmic sketches, generative art, visual experiments, and mathematical images.",
|
|
},
|
|
];
|
|
|
|
const socialLinks = [
|
|
{
|
|
href: "https://x.com/home",
|
|
label: "X",
|
|
color: "#525252",
|
|
svg: `<path d="M5 5l14 14"></path><path d="M19 5L5 19"></path>`,
|
|
},
|
|
{
|
|
href: "https://bsky.app/profile/neeldhara.bsky.social",
|
|
label: "Bluesky",
|
|
color: "#4f83b8",
|
|
svg: `<path d="M12 13c-2.9-5.2-6-7.2-8.1-6.4-1.6.7-1 4.4 2.3 7.1"></path><path d="M12 13c2.9-5.2 6-7.2 8.1-6.4 1.6.7 1 4.4-2.3 7.1"></path><path d="M12 13c-2.3 1.6-3.6 3.1-3.3 4.6.3 1.4 2.1 1.8 3.3.2"></path><path d="M12 13c2.3 1.6 3.6 3.1 3.3 4.6-.3 1.4-2.1 1.8-3.3.2"></path>`,
|
|
},
|
|
{
|
|
href: "https://www.linkedin.com/in/neeldhara-misra-a54b6920/",
|
|
label: "LinkedIn",
|
|
color: "#496f9e",
|
|
svg: `<rect x="4.5" y="4.5" width="15" height="15" rx="2.5"></rect><path d="M8 10.5v5.5"></path><path d="M11.5 16v-5.5"></path><path d="M11.5 13.1c0-1.7 1-2.7 2.3-2.7 1.5 0 2.2 1 2.2 2.7V16"></path><path d="M8 8h.01"></path>`,
|
|
},
|
|
{
|
|
href: "https://mathstodon.xyz/@neeldhara",
|
|
label: "Mastodon",
|
|
color: "#7464a8",
|
|
svg: `<path d="M7 16.5c1.4.8 3.1 1.1 5.2 1 3.7-.1 5.8-1.7 6.1-4.8l.2-3.6c.2-3.1-1.4-4.7-4.5-4.7h-4c-3.1 0-4.7 1.6-4.5 4.7l.3 5.1c.2 3.4 2.2 5.2 5.9 5.3"></path><path d="M8.5 13V9.6c0-1.1.7-1.8 1.7-1.8.8 0 1.4.4 1.8 1.1.4-.7 1-1.1 1.8-1.1 1 0 1.7.7 1.7 1.8V13"></path><path d="M12 9.4V13"></path>`,
|
|
},
|
|
{
|
|
href: "https://www.youtube.com/@neeldhara",
|
|
label: "YouTube",
|
|
color: "#b55252",
|
|
svg: `<path d="M4.8 8.5c.2-1.2 1.1-2.1 2.3-2.2 1.6-.2 3.2-.3 4.9-.3s3.3.1 4.9.3c1.2.1 2.1 1 2.3 2.2.2 1.1.3 2.2.3 3.5s-.1 2.4-.3 3.5c-.2 1.2-1.1 2.1-2.3 2.2-1.6.2-3.2.3-4.9.3s-3.3-.1-4.9-.3c-1.2-.1-2.1-1-2.3-2.2-.2-1.1-.3-2.2-.3-3.5s.1-2.4.3-3.5Z"></path><path d="M10.5 9.5l4 2.5-4 2.5v-5Z"></path>`,
|
|
},
|
|
{
|
|
href: "https://www.instagram.com/neeldharamisra",
|
|
label: "Instagram",
|
|
color: "#9a6b8c",
|
|
svg: `<rect x="5" y="5" width="14" height="14" rx="4"></rect><circle cx="12" cy="12" r="3.2"></circle><path d="M16.3 7.8h.01"></path>`,
|
|
},
|
|
];
|
|
|
|
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",
|
|
"heliotrope_3_bold.woff2",
|
|
"heliotrope_3_bold_italic.woff2",
|
|
];
|
|
|
|
function escapeHtml(value) {
|
|
return value
|
|
.toString()
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function parseScalar(value) {
|
|
const trimmed = value.trim();
|
|
|
|
if (trimmed === "true") return true;
|
|
if (trimmed === "false") return false;
|
|
|
|
if (
|
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
) {
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
|
|
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) {
|
|
const date = post.date ? new Date(`${post.date}T00:00:00Z`) : null;
|
|
const dateLabel = date
|
|
? date.toLocaleDateString("en", { month: "short", day: "numeric", timeZone: "UTC" })
|
|
: "";
|
|
|
|
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`;
|
|
|
|
return `<a class="icon-link" href="${escapeHtml(href)}" aria-label="${escapeHtml(blog.label)} RSS">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle cx="6.25" cy="17.75" r="1.8"></circle>
|
|
<path d="M4.5 5.5c7.7 0 14 6.3 14 14"></path>
|
|
<path d="M4.5 10.2a9.3 9.3 0 0 1 9.3 9.3"></path>
|
|
</svg>
|
|
</a>`;
|
|
}
|
|
|
|
function renderSiteIcon(blog) {
|
|
const href = `https://${blog.slug}.${publicHostSuffix}/`;
|
|
|
|
return `<a class="icon-link" href="${escapeHtml(href)}" aria-label="${escapeHtml(blog.label)} site">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="8.25"></circle>
|
|
<path d="M3.75 12h16.5"></path>
|
|
<path d="M12 3.75c2.2 2.25 3.3 5 3.3 8.25s-1.1 6-3.3 8.25"></path>
|
|
<path d="M12 3.75c-2.2 2.25-3.3 5-3.3 8.25s1.1 6 3.3 8.25"></path>
|
|
</svg>
|
|
</a>`;
|
|
}
|
|
|
|
function renderSocialLinks() {
|
|
return `<nav class="social-links" aria-label="Social links">
|
|
${socialLinks.map((link) => `<a class="social-link" href="${escapeHtml(link.href)}" aria-label="${escapeHtml(link.label)}" style="--social-color:${escapeHtml(link.color)}" target="_blank" rel="me noopener noreferrer">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">${link.svg}</svg>
|
|
</a>`).join("\n ")}
|
|
</nav>`;
|
|
}
|
|
|
|
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>
|
|
<span class="blog-actions">
|
|
${renderSiteIcon(blog)}
|
|
${renderRssIcon(blog)}
|
|
</span>
|
|
</summary>
|
|
<p>${escapeHtml(blog.summary)}</p>
|
|
${renderFeaturedPost(featured)}
|
|
<ul>
|
|
${latest.map(renderPost).join("\n ")}
|
|
</ul>
|
|
</details>`;
|
|
}
|
|
|
|
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">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>neeldhara.blog</title>
|
|
<style>
|
|
@font-face {
|
|
font-family: "Heliotrope";
|
|
src: url("/webfonts/heliotrope_3_regular.woff2") format("woff2");
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
font-display: swap;
|
|
}
|
|
@font-face {
|
|
font-family: "Heliotrope";
|
|
src: url("/webfonts/heliotrope_3_italic.woff2") format("woff2");
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
font-display: swap;
|
|
}
|
|
@font-face {
|
|
font-family: "Heliotrope";
|
|
src: url("/webfonts/heliotrope_3_bold.woff2") format("woff2");
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
font-display: swap;
|
|
}
|
|
@font-face {
|
|
font-family: "Heliotrope";
|
|
src: url("/webfonts/heliotrope_3_bold_italic.woff2") format("woff2");
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
font-display: swap;
|
|
}
|
|
html { color-scheme: light; }
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: #f0f0f0;
|
|
color: #111;
|
|
font-family: "Heliotrope", ui-serif, Georgia, serif;
|
|
font-size: 15px;
|
|
line-height: 1.45;
|
|
}
|
|
a {
|
|
color: inherit;
|
|
text-decoration-color: #c6c6c6;
|
|
text-underline-offset: 3px;
|
|
}
|
|
a:hover { text-decoration-color: #111; }
|
|
main {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.75fr) minmax(320px, 1fr);
|
|
width: min(1220px, calc(100% - 72px));
|
|
min-height: min(860px, calc(100vh - 144px));
|
|
margin: 72px auto;
|
|
background: #d9d9d9;
|
|
}
|
|
.left,
|
|
.right {
|
|
min-height: inherit;
|
|
padding: 34px 32px;
|
|
}
|
|
.left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
gap: 56px;
|
|
}
|
|
.right {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
background: #fff;
|
|
}
|
|
.mark {
|
|
display: block;
|
|
width: 13px;
|
|
height: 13px;
|
|
border: 2px solid #111;
|
|
border-radius: 50%;
|
|
}
|
|
h1,
|
|
h2,
|
|
p {
|
|
margin: 0;
|
|
}
|
|
h1,
|
|
h2 {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
}
|
|
.role {
|
|
color: #666;
|
|
font-weight: 500;
|
|
}
|
|
.identity {
|
|
display: grid;
|
|
gap: 7px;
|
|
}
|
|
.social-links {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 13px;
|
|
margin-top: 7px;
|
|
}
|
|
.social-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
color: #222;
|
|
opacity: 0.72;
|
|
transition: color 180ms ease, opacity 180ms ease, transform 180ms ease;
|
|
}
|
|
.social-link:hover {
|
|
color: var(--social-color);
|
|
opacity: 1;
|
|
transform: translateY(-1px);
|
|
}
|
|
.social-link svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 1.7;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
.blog-list {
|
|
display: grid;
|
|
gap: 28px;
|
|
}
|
|
.blog {
|
|
max-width: 650px;
|
|
}
|
|
summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
width: 100%;
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
list-style: none;
|
|
}
|
|
summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
summary::before {
|
|
content: "+";
|
|
width: 18px;
|
|
flex: 0 0 18px;
|
|
color: #666;
|
|
font-weight: 500;
|
|
}
|
|
details[open] summary::before {
|
|
content: "-";
|
|
}
|
|
summary > span {
|
|
flex: 0 1 auto;
|
|
}
|
|
.blog-actions {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.icon-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
color: #111;
|
|
}
|
|
.icon-link svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 1.9;
|
|
stroke-linecap: round;
|
|
}
|
|
.icon-link circle {
|
|
fill: none;
|
|
}
|
|
.icon-link[aria-label$="RSS"] circle {
|
|
fill: currentColor;
|
|
stroke: none;
|
|
}
|
|
.blog p {
|
|
max-width: 540px;
|
|
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;
|
|
margin: 14px 0 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
.post {
|
|
display: grid;
|
|
grid-template-columns: 64px minmax(0, 1fr);
|
|
gap: 0;
|
|
}
|
|
.date {
|
|
color: #666;
|
|
}
|
|
.post a {
|
|
font-weight: 700;
|
|
}
|
|
.bio {
|
|
display: grid;
|
|
gap: 24px;
|
|
max-width: 390px;
|
|
font-weight: 400;
|
|
}
|
|
@media (max-width: 860px) {
|
|
main {
|
|
grid-template-columns: 1fr;
|
|
width: min(100%, calc(100% - 28px));
|
|
margin: 14px auto;
|
|
}
|
|
.left,
|
|
.right {
|
|
min-height: auto;
|
|
padding: 28px 26px;
|
|
}
|
|
.left {
|
|
gap: 40px;
|
|
}
|
|
.right {
|
|
gap: 72px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<section class="left" aria-label="Blog index">
|
|
<span class="mark" aria-hidden="true"></span>
|
|
<div class="blog-list">
|
|
${entries.map(([blog, posts]) => renderBlog(blog, posts)).join("\n ")}
|
|
</div>
|
|
</section>
|
|
<aside class="right" aria-label="About">
|
|
<div class="identity">
|
|
<h2>Neeldhara Misra</h2>
|
|
<p class="role">Computer Science and Engineering, IIT Gandhinagar</p>
|
|
${renderSocialLinks()}
|
|
</div>
|
|
<div class="bio">
|
|
<p>I am a computer science faculty member at IIT Gandhinagar. My academic work is broadly around algorithms, structural graph theory, parameterized complexity, computational social choice, combinatorial games, and fair allocation.</p>
|
|
<p>These blogs separate different kinds of notes: paper-reading and mini-surveys, puzzle-like teaching prompts, reflective essays, poems, reviews of tools and workflows, experiments with AI, and visual or generative art.</p>
|
|
<p>The homepage is deliberately small: each section shows the two newest posts and links to that blog's RSS feed.</p>
|
|
</div>
|
|
</aside>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
await mkdir(outDir, { recursive: true });
|
|
await writeFile(new URL("index.html", outDir), html);
|
|
await mkdir(new URL("webfonts/", outDir), { recursive: true });
|
|
await Promise.all(
|
|
fontFiles.map((file) => copyFile(new URL(file, fontDir), new URL(`webfonts/${file}`, outDir))),
|
|
);
|