Files
dev-notes/bin/build.js
Keith Solomon e6f6ea5846
Some checks failed
build-site / build (push) Failing after 6m4s
feature: First working version with dummy content
2025-11-29 12:27:20 -06:00

269 lines
8.1 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const MarkdownIt = require('markdown-it');
const hljs = require('highlight.js');
const ROOT = path.resolve(__dirname, '..');
const CONTENT_DIR = path.join(ROOT, 'content');
const DIST_DIR = path.join(ROOT, 'dist');
const TEMPLATE_DIR = path.join(ROOT, 'templates');
const ASSETS_DIR = path.join(ROOT, 'assets');
const md = new MarkdownIt({
html: true,
linkify: true,
highlight: (str, lang) => {
if (lang && hljs.getLanguage(lang)) {
return `<pre><code class="hljs ${lang}">${hljs.highlight(str, { language: lang }).value}</code></pre>`;
}
return `<pre><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`;
},
});
const readTemplate = (name) => fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf8');
const templates = {
base: readTemplate('base.html'),
header: readTemplate('header.html'),
sidebar: readTemplate('sidebar.html'),
footer: readTemplate('footer.html'),
};
const render = (template, data) => template.replace(/{{\s*(\w+)\s*}}/g, (_, key) => data[key] ?? '');
const slugify = (value) =>
value
.toString()
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const walkMarkdownFiles = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) return walkMarkdownFiles(fullPath);
if (entry.isFile() && entry.name.endsWith('.md')) return [fullPath];
return [];
});
};
const cleanDir = (dir) => {
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
};
const stripMarkdown = (text) => text.replace(/[`*_>#\-]/g, '').replace(/\s+/g, ' ').trim();
const loadNotes = () => {
const files = walkMarkdownFiles(CONTENT_DIR);
return files.map((filePath) => {
const raw = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(raw);
const relative = path.relative(CONTENT_DIR, filePath);
const fileName = path.basename(filePath, '.md');
const section = data.section || path.dirname(relative) || 'notes';
const sectionSlug = slugify(section);
const slug = slugify(data.slug || fileName);
const title = data.title || fileName.replace(/-/g, ' ');
const html = md.render(content);
const summary = (data.summary || stripMarkdown(content).slice(0, 180)) + '...';
return {
title,
section,
sectionSlug,
slug,
summary,
tags: data.tags || [],
nav: data.nav ?? Number.MAX_SAFE_INTEGER,
html,
sourcePath: relative,
};
});
};
const buildNavLinks = (sections, activeSection) => {
return sections
.map(({ name, slug }) => {
const isActive = activeSection === slug ? 'is-active' : '';
return `<a class="${isActive}" href="/${slug}/">${name}</a>`;
})
.join('');
};
const buildSidebarLinks = (notes, currentSlug) =>
notes
.map((note) => {
const isActive = currentSlug === note.slug ? 'is-active' : '';
return `<li><a class="${isActive}" href="/${note.sectionSlug}/${note.slug}/">${note.title}</a></li>`;
})
.join('');
const assemblePage = ({ pageTitle, header, sidebar, content, extraHead = '' }) =>
render(templates.base, { pageTitle, header, sidebar, content, footer: templates.footer, extraHead });
const writePage = (targetPath, html) => {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, html, 'utf8');
};
const copyAssets = () => {
const target = path.join(DIST_DIR, 'assets');
fs.cpSync(ASSETS_DIR, target, { recursive: true });
};
const buildSearchIndex = (notes) => {
const index = notes.map((note) => ({
title: note.title,
section: note.section,
summary: note.summary,
tags: note.tags,
url: `/${note.sectionSlug}/${note.slug}/`,
}));
fs.writeFileSync(path.join(DIST_DIR, 'search-index.json'), JSON.stringify(index, null, 2));
};
const buildPages = () => {
cleanDir(DIST_DIR);
const notes = loadNotes();
const sectionsMap = new Map();
notes.forEach((note) => {
if (!sectionsMap.has(note.sectionSlug)) {
sectionsMap.set(note.sectionSlug, {
name: note.section,
slug: note.sectionSlug,
notes: [],
});
}
sectionsMap.get(note.sectionSlug).notes.push(note);
});
const sections = Array.from(sectionsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
sections.forEach((section) => section.notes.sort((a, b) => (a.nav - b.nav) || a.title.localeCompare(b.title)));
// Build header once per page.
const renderHeader = (activeSectionSlug) => render(templates.header, {
navLinks: buildNavLinks(sections, activeSectionSlug),
});
// Section pages and notes
sections.forEach((section) => {
const sidebar = render(templates.sidebar, {
sectionTitle: section.name,
sidebarLinks: buildSidebarLinks(section.notes, null),
});
// Section index
const sectionList = section.notes
.map((note) => `
<a class="card" href="/${note.sectionSlug}/${note.slug}/">
<div class="badge">${section.name}</div>
<h3>${note.title}</h3>
<p class="muted">${note.summary}</p>
<div class="tag-list">${note.tags.map((tag) => `<span class="tag">#${tag}</span>`).join('')}</div>
</a>
`)
.join('');
const sectionContent = `
<div class="hero">
<p class="eyebrow">Section</p>
<h1>${section.name}</h1>
<p class="muted">Notes grouped by ${section.name}. Use the sidebar or search to jump in.</p>
</div>
<div class="card-grid">${sectionList}</div>
`;
const sectionPage = assemblePage({
pageTitle: section.name,
header: renderHeader(section.slug),
sidebar,
content: sectionContent,
});
writePage(path.join(DIST_DIR, section.slug, 'index.html'), sectionPage);
// Individual notes
section.notes.forEach((note) => {
const sidebarWithActive = render(templates.sidebar, {
sectionTitle: section.name,
sidebarLinks: buildSidebarLinks(section.notes, note.slug),
});
const tags = note.tags.map((tag) => `<span class="tag">#${tag}</span>`).join('');
const noteContent = `
<article>
<p class="eyebrow">${section.name}</p>
<h1>${note.title}</h1>
<div class="note-meta">
<span>${note.summary}</span>
${tags ? `<span class="tag-list">${tags}</span>` : ''}
</div>
<div class="divider"></div>
${note.html}
</article>
`;
const page = assemblePage({
pageTitle: note.title,
header: renderHeader(section.slug),
sidebar: sidebarWithActive,
content: noteContent,
});
writePage(path.join(DIST_DIR, section.slug, note.slug, 'index.html'), page);
});
});
// Home page
const summaryCards = sections
.map((section) => `
<a class="card" href="/${section.slug}/">
<div class="badge">${section.notes.length} note${section.notes.length === 1 ? '' : 's'}</div>
<h3>${section.name}</h3>
<p class="muted">${section.notes[0]?.summary || 'Section overview'}
</p>
</a>
`)
.join('');
const homeContent = `
<div class="hero">
<p class="eyebrow">Workspace</p>
<h1>Developer Notes Hub</h1>
<p class="muted">Markdown-first notes rendered into a fast static site. Use search or browse by section.</p>
</div>
<div class="card-grid">${summaryCards}</div>
`;
const homeSidebar = render(templates.sidebar, {
sectionTitle: 'Sections',
sidebarLinks: sections
.map((section) => `<li><a href="/${section.slug}/">${section.name}</a></li>`)
.join(''),
});
const homePage = assemblePage({
pageTitle: 'Home',
header: renderHeader(null),
sidebar: homeSidebar,
content: homeContent,
});
writePage(path.join(DIST_DIR, 'index.html'), homePage);
// Assets and search index
copyAssets();
buildSearchIndex(notes);
console.log(`Built ${notes.length} notes across ${sections.length} sections → ${DIST_DIR}`);
};
buildPages();