diff --git a/README.md b/README.md index 93c7ec2..681a746 100644 --- a/README.md +++ b/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 +``` diff --git a/content/blog-index.json b/content/blog-index.json new file mode 100644 index 0000000..a6b5d84 --- /dev/null +++ b/content/blog-index.json @@ -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 + } + ] +} diff --git a/package.json b/package.json index d63db35..3d7b6ff 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index b050638..e3374db 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -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, """); } -function textFrom(block, tag) { - const match = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, "i")); - return match ? decodeXml(match[1].trim()) : ""; -} +function parseScalar(value) { + const trimmed = value.trim(); -function parseItems(xml) { - return [...xml.matchAll(/([\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 `
  • ${escapeHtml(dateLabel)}${escapeHtml(post.title)}
  • `; } +function renderFeaturedPost(post) { + if (!post) return ""; + + return `

    Featured${escapeHtml(post.title)}

    `; +} + 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 `
    ${escapeHtml(blog.label)} @@ -186,15 +302,26 @@ function renderBlog(blog, posts) {

    ${escapeHtml(blog.summary)}

    + ${renderFeaturedPost(featured)}
    `; } -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 = ` @@ -393,6 +520,18 @@ const 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;