269 lines
8.1 KiB
JavaScript
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();
|