diff --git a/build.js b/build.js
index b74919a..ffd2298 100644
--- a/build.js
+++ b/build.js
@@ -3,6 +3,7 @@ const path = require('path');
const esbuild = require('esbuild');
const { execSync } = require('child_process');
const { StaticBlogGenerator } = require('./tools/StaticBlogGenerator');
+const { renderNavLinkItems } = require('./tools/navLinks');
/**
* Parse YAML front matter from markdown content
@@ -38,13 +39,14 @@ function parseMarkdownWithFrontmatter(content) {
/**
* Process blog markdown files and generate blogs.json, also update graph.json
+ * @param {string} buildDir - Path to the build output directory
*/
-async function processBlogPosts() {
+async function processBlogPosts(buildDir) {
const blogDir = path.join(__dirname, 'website', 'data', 'blog');
- const outputPath = path.join(__dirname, 'website', 'data', 'blogs.json');
- const graphPath = path.join(__dirname, 'website', 'data', 'graph.json');
+ const outputPath = path.join(buildDir, 'data', 'blogs.json');
+ const graphPath = path.join(buildDir, 'data', 'graph.json');
const staticBlogGenerator = new StaticBlogGenerator({
- siteRootDir: path.join(__dirname, 'website')
+ siteRootDir: buildDir
});
try {
@@ -322,6 +324,11 @@ async function updateHtmlForBundle() {
);
html = html.replace(/src="\/scripts\/blogPage\.js"/g, 'src="/scripts/blogPage.bundle.js"');
+
+ html = html.replace(
+ /[ \t]*/g,
+ renderNavLinkItems(' ')
+ );
await fs.writeFile(filePath, html);
}
@@ -357,8 +364,9 @@ async function updateHtmlForBundle() {
// Run the build
async function build() {
- await processBlogPosts();
+ const buildDir = path.join(__dirname, 'build');
await copyWebsiteFiles();
+ await processBlogPosts(buildDir);
await bundleJavaScript();
await minifyCSS();
await updateHtmlForBundle();
diff --git a/tools/StaticBlogGenerator.js b/tools/StaticBlogGenerator.js
index 2120606..d70d4f5 100644
--- a/tools/StaticBlogGenerator.js
+++ b/tools/StaticBlogGenerator.js
@@ -2,6 +2,7 @@
const fs = require('fs').promises;
const path = require('path');
+const { renderNavLinkItems } = require('./navLinks');
/**
* Generates static blog pages and an index page from markdown post data.
@@ -32,10 +33,13 @@ class StaticBlogGenerator {
const indexHtml = this.renderIndexPage(posts);
await fs.writeFile(this.blogIndexPath, indexHtml, 'utf-8');
- for (const post of posts) {
+ for (let i = 0; i < posts.length; i++) {
+ const post = posts[i];
+ const prev = posts[i + 1] || null;
+ const next = posts[i - 1] || null;
const postDir = path.join(this.blogRootDir, post.slug);
await fs.mkdir(postDir, { recursive: true });
- const postHtml = this.renderPostPage(post);
+ const postHtml = this.renderPostPage(post, prev, next);
await fs.writeFile(path.join(postDir, 'index.html'), postHtml, 'utf-8');
}
}
@@ -82,9 +86,11 @@ class StaticBlogGenerator {
/**
* @param {{slug: string, title: string, date: string | null, author: string | null, tags?: string[], content: string}} post
+ * @param {{slug: string, title: string, date: string | null} | null} prev - Older post (lower index)
+ * @param {{slug: string, title: string, date: string | null} | null} next - Newer post (higher index)
* @returns {string}
*/
- renderPostPage(post) {
+ renderPostPage(post, prev = null, next = null) {
const safeTitle = this.escapeHtml(post.title || post.slug);
const dateLabel = post.date ? this.formatDate(post.date) : null;
const authorLabel = post.author ? this.escapeHtml(post.author) : null;
@@ -100,18 +106,58 @@ class StaticBlogGenerator {
const markdownJson = JSON.stringify(post.content || '').replace(/<\//g, '<\\/');
+ const prevPeek = prev
+ ? [
+ `
`,
+ ].join('\n')
+ : '';
+
+ const nextPeek = next
+ ? [
+ ``,
+ ].join('\n')
+ : '';
+
const content = [
'',
this.renderBrandBar(),
+ '',
+ prevPeek,
'
',
' ',
+ '
',
' ',
` `,
'',
+ nextPeek,
+ '
',
+ '
',
''
].join('\n');
@@ -166,13 +212,7 @@ class StaticBlogGenerator {
* @returns {string}
*/
renderHeaderLinks() {
- return [
- ' blog',
- ' docs',
- ' github',
- ' bluesky',
- ' materials'
- ].join('\n');
+ return renderNavLinkItems(' ');
}
/**
diff --git a/tools/blogeditor/blog-editor-0.1.11.vsix b/tools/blogeditor/blog-editor-0.1.11.vsix
deleted file mode 100644
index 3d59106..0000000
Binary files a/tools/blogeditor/blog-editor-0.1.11.vsix and /dev/null differ
diff --git a/tools/navLinks.js b/tools/navLinks.js
new file mode 100644
index 0000000..a633e81
--- /dev/null
+++ b/tools/navLinks.js
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * @typedef {Object} NavLink
+ * @property {string} label
+ * @property {string} href
+ * @property {boolean} [external]
+ */
+
+/** @type {NavLink[]} */
+const NAV_LINKS = [
+ { label: 'blog', href: '/blog.html' },
+ { label: 'docs', href: '/docs/' },
+ { label: 'github', href: 'https://github.com/litruv', external: true },
+ { label: 'bluesky', href: 'https://bsky.app/profile/lit.mates.dev', external: true },
+ { label: 'materials', href: '/materials' },
+];
+
+/**
+ * Renders nav link `` elements as an HTML string.
+ *
+ * @param {string} [indent=' '] - Leading whitespace for each line.
+ * @returns {string}
+ */
+function renderNavLinkItems(indent = ' ') {
+ return NAV_LINKS.map(({ label, href, external }) => {
+ const attrs = external ? ' target="_blank" rel="noopener"' : '';
+ return `${indent}${label}`;
+ }).join('\n');
+}
+
+module.exports = { NAV_LINKS, renderNavLinkItems };
diff --git a/website/blog.html b/website/blog.html
deleted file mode 100644
index bb9dddf..0000000
--- a/website/blog.html
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-
-
-
-
- Blog
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- September 11, 2026 - Test Author
-testformattingmarkdown
-
-
-
- May 10, 2026 - it me
-
-
-
-
- May 10, 2026 - Max Litruv Boonzaayer
-welcomemetaintroduction
-
-
-
- April 15, 2026 - Max Litruv Boonzaayer
-javascriptgraph-systemstutorial
-
-
-
-
-
-
\ No newline at end of file
diff --git a/website/blog/markdown-test/index.html b/website/blog/markdown-test/index.html
deleted file mode 100644
index 7e471c9..0000000
--- a/website/blog/markdown-test/index.html
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
- Markdown Formatting Test - Blog
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/website/blog/node-graphs/index.html b/website/blog/node-graphs/index.html
deleted file mode 100644
index b57a2d8..0000000
--- a/website/blog/node-graphs/index.html
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
- Building Interactive Node Graphs - Blog
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/website/blog/testddd/index.html b/website/blog/testddd/index.html
deleted file mode 100644
index 6ff52e4..0000000
--- a/website/blog/testddd/index.html
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
- test d d d d - Blog
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/website/blog/welcome/index.html b/website/blog/welcome/index.html
deleted file mode 100644
index 0f72aca..0000000
--- a/website/blog/welcome/index.html
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
- Welcome to My Blog - Blog
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/website/index.html b/website/index.html
index 0ca7d0e..6d18eca 100644
--- a/website/index.html
+++ b/website/index.html
@@ -50,11 +50,7 @@
diff --git a/website/scripts/MarkdownRenderer.js b/website/scripts/MarkdownRenderer.js
index 1a12d13..a245180 100644
--- a/website/scripts/MarkdownRenderer.js
+++ b/website/scripts/MarkdownRenderer.js
@@ -97,14 +97,17 @@ export class MarkdownRenderer {
// Horizontal rule
if (/^[-*_]{3,}$/.test(block)) {
- return `
`;
+ return `
`;
}
// Headings
const headingMatch = block.match(/^(#{1,6})\s+(.+)$/m);
if (headingMatch && block.split("\n").length === 1) {
const level = headingMatch[1].length;
- return `
${MarkdownRenderer.#renderInline(headingMatch[2])}`;
+ const pin = level === 1 ? '
'
+ : level === 2 ? '
'
+ : '';
+ return `
${pin}${MarkdownRenderer.#renderInline(headingMatch[2])}`;
}
// Tables
diff --git a/website/scripts/blogPage.js b/website/scripts/blogPage.js
index dcef1d9..f23b5ee 100644
--- a/website/scripts/blogPage.js
+++ b/website/scripts/blogPage.js
@@ -95,6 +95,106 @@ function setupScrollTopBar() {
}
}
+/**
+ * Draws SVG bezier splines from the peek-card exec pins to the main blog card.
+ *
+ * @returns {void}
+ */
+function setupPeekSplines() {
+ const svg = document.querySelector(".blog-post-splines");
+ const layout = document.querySelector(".blog-post-layout");
+ const card = document.querySelector(".blog-card");
+ if (!svg || !layout || !card) return;
+
+ const WIRE_COLOR = "#ffffff";
+ const WIRE_OPACITY = "0.35";
+
+ /**
+ * Creates an SVG path element styled as an exec wire.
+ *
+ * @returns {SVGPathElement}
+ */
+ function makePath() {
+ const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ p.setAttribute("fill", "none");
+ p.setAttribute("stroke", WIRE_COLOR);
+ p.setAttribute("stroke-width", "2.5");
+ p.setAttribute("stroke-linecap", "round");
+ p.style.opacity = WIRE_OPACITY;
+ return p;
+ }
+
+ /**
+ * Returns the center of an element relative to the layout container.
+ *
+ * @param {Element} el
+ * @returns {{x: number, y: number}}
+ */
+ function centerOf(el) {
+ const er = el.getBoundingClientRect();
+ const lr = layout.getBoundingClientRect();
+ return {
+ x: er.left - lr.left + er.width / 2,
+ y: er.top - lr.top + er.height / 2,
+ };
+ }
+
+ /**
+ * Returns the midpoint of a card's left or right edge relative to the layout.
+ *
+ * @param {Element} el
+ * @param {'left'|'right'} side
+ * @returns {{x: number, y: number}}
+ */
+ function edgeOf(el, side) {
+ const er = el.getBoundingClientRect();
+ const lr = layout.getBoundingClientRect();
+ return {
+ x: side === 'left' ? er.left - lr.left : er.right - lr.left,
+ y: er.top - lr.top + 158,
+ };
+ }
+
+ const pathPrev = makePath();
+ const pathNext = makePath();
+ svg.appendChild(pathPrev);
+ svg.appendChild(pathNext);
+
+ /** @returns {void} */
+ function draw() {
+ const lr = layout.getBoundingClientRect();
+ svg.setAttribute("width", String(lr.width));
+ svg.setAttribute("height", String(lr.height));
+
+ const prevPin = document.querySelector("[data-peek-pin='prev-out']");
+ const nextPin = document.querySelector("[data-peek-pin='next-in']");
+ const cardLeft = edgeOf(card, "left");
+ const cardRight = edgeOf(card, "right");
+
+ if (prevPin) {
+ const s = centerOf(prevPin);
+ const e = cardLeft;
+ const cp = Math.max(60, Math.abs(e.x - s.x) * 0.5);
+ pathPrev.setAttribute("d", `M ${s.x} ${s.y} C ${s.x + cp} ${s.y} ${e.x - cp} ${e.y} ${e.x} ${e.y}`);
+ } else {
+ pathPrev.setAttribute("d", "");
+ }
+
+ if (nextPin) {
+ const s = cardRight;
+ const e = centerOf(nextPin);
+ const cp = Math.max(60, Math.abs(e.x - s.x) * 0.5);
+ pathNext.setAttribute("d", `M ${s.x} ${s.y} C ${s.x + cp} ${s.y} ${e.x - cp} ${e.y} ${e.x} ${e.y}`);
+ } else {
+ pathNext.setAttribute("d", "");
+ }
+ }
+
+ draw();
+ window.addEventListener("resize", draw, { passive: true });
+ window.addEventListener("scroll", draw, { passive: true });
+}
+
/**
* Bootstraps static blog page behaviors.
*
@@ -103,6 +203,7 @@ function setupScrollTopBar() {
function initializeBlogPage() {
renderStaticBlogPost();
setupScrollTopBar();
+ setupPeekSplines();
}
if (document.readyState === "loading") {
diff --git a/website/styles/main.css b/website/styles/main.css
index f23b505..f3e34db 100644
--- a/website/styles/main.css
+++ b/website/styles/main.css
@@ -691,7 +691,7 @@ body.blog-page {
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
+ justify-content: flex-start;
padding: 110px 16px 36px;
}
@@ -718,15 +718,150 @@ body.blog-page {
border: 1px solid rgba(205, 214, 244, 0.14);
border-radius: 16px;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.35);
- padding: 22px 24px 28px;
+ padding: 22px 24px 28px 48px;
}
.blog-post-header {
- border-bottom: 1px solid rgba(205, 214, 244, 0.12);
padding-bottom: 14px;
- margin-bottom: 16px;
+ margin-bottom: 0;
}
+/* ─── Post layout with prev/next peek cards ───────────────────────────────────── */
+
+.blog-post-layout {
+ width: min(900px, 100%);
+ margin: 0 auto;
+ position: relative;
+ overflow: visible;
+ isolation: isolate;
+}
+
+.blog-post-peek-wrapper {
+ position: absolute;
+ top: 0;
+ width: 160px;
+ height: 0;
+ pointer-events: none;
+ overflow: visible;
+}
+
+.blog-post-peek-wrapper--prev {
+ right: calc(100% + 100px);
+}
+
+.blog-post-peek-wrapper--next {
+ left: calc(100% + 100px);
+}
+
+.blog-post-peek {
+ width: 160px;
+ background: var(--node-bg);
+ border: 1px solid var(--node-border);
+ border-radius: 8px;
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ opacity: 0.7;
+ transition: opacity 0.2s ease, border-color 0.2s ease;
+ text-decoration: none;
+ position: sticky;
+ top: 80px;
+ pointer-events: auto;
+ margin-top: 121px;
+}
+
+.blog-post-peek:hover {
+ opacity: 1;
+ border-color: rgba(250, 179, 135, 0.35);
+}
+
+.blog-post-peek--empty {
+ pointer-events: none;
+ background: transparent;
+ border-color: transparent;
+ box-shadow: none;
+}
+
+.blog-post-peek-header {
+ background: linear-gradient(90deg, var(--ctp-base), var(--ctp-surface0));
+ border-bottom: 1px solid var(--node-border);
+ padding: 6px 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.blog-post-peek-direction {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--ctp-overlay2);
+ flex: 1;
+}
+
+.blog-post-peek-body {
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.blog-post-peek-title {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--ctp-text);
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.blog-post-peek-date {
+ font-size: 0.68rem;
+ color: var(--ctp-subtext0);
+}
+
+/* exec pin dot on peek cards */
+.blog-post-peek-pin {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ background: #ffffff;
+ clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%);
+}
+
+.blog-post-peek--next {
+ margin-top: 221px;
+}
+
+.blog-post-peek--next .blog-post-peek-pin {
+}
+
+/* spline SVG overlay */
+.blog-post-splines {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: visible;
+ z-index: 10;
+}
+
+.blog-post-layout > .blog-card {
+ position: relative;
+}
+
+@media (max-width: 1100px) {
+ .blog-post-peek {
+ display: none;
+ }
+ .blog-post-splines {
+ display: none;
+ }
+}
+
+
.blog-post-title {
margin: 0;
color: var(--ctp-text);
@@ -771,6 +906,57 @@ body.blog-page {
user-select: text;
}
+.blog-post-content .md-hr,
+.blog-card > .md-hr {
+ border: none;
+ margin: 2em -8px;
+ width: calc(100% + 16px);
+ display: flex;
+ align-items: center;
+ gap: 0;
+ height: 14px;
+ position: relative;
+}
+
+.blog-card > .md-hr {
+ margin: 14px -8px 16px;
+ width: calc(100% + 16px);
+}
+
+.blog-post-content .md-hr::before,
+.blog-card > .md-hr::before {
+ content: '';
+ display: block;
+ flex-shrink: 0;
+ width: 14px;
+ height: 14px;
+ background: var(--ctp-text);
+ clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%);
+}
+
+.blog-post-content .md-hr::after,
+.blog-card > .md-hr::after {
+ content: none;
+}
+
+.md-hr-line {
+ display: block;
+ flex: 1;
+ height: 3px;
+ background: var(--ctp-text);
+ opacity: 0.4;
+}
+
+.md-hr-arrow {
+ display: block;
+ flex-shrink: 0;
+ width: 14px;
+ height: 14px;
+ background: var(--ctp-text);
+ clip-path: polygon(0% 0%, 60% 0%, 100% 50%, 60% 100%, 0% 100%);
+ opacity: 0.4;
+}
+
.blog-post-content .md-h1,
.blog-post-content .md-h2,
.blog-post-content .md-h3,
@@ -783,8 +969,60 @@ body.blog-page {
line-height: 1.3;
}
-.blog-post-content .md-h1 { font-size: 1.9rem; }
-.blog-post-content .md-h2 { font-size: 1.55rem; }
+.blog-post-content .md-h1 {
+ font-size: 1.9rem;
+ position: relative;
+}
+
+.md-h1-pin {
+ display: inline-flex;
+ flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 2px solid var(--node-border);
+ background: var(--ctp-surface0);
+ color: #3ee581;
+ position: absolute;
+ left: -37px;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.md-h1-pin::after {
+ content: '';
+ position: absolute;
+ inset: 3px;
+ border-radius: 50%;
+ background: currentColor;
+}
+.blog-post-content .md-h2 {
+ font-size: 1.55rem;
+ position: relative;
+}
+
+.md-h2-pin {
+ display: inline-flex;
+ flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 2px solid var(--node-border);
+ background: var(--ctp-surface0);
+ color: #ff66ff;
+ position: absolute;
+ left: -37px;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.md-h2-pin::after {
+ content: '';
+ position: absolute;
+ inset: 3px;
+ border-radius: 50%;
+ background: currentColor;
+}
.blog-post-content .md-h3 { font-size: 1.3rem; }
.blog-post-content .md-h4 { font-size: 1.1rem; }