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 + ? [ + `
`, + ``, + `
`, + ` ← older`, + ` `, + `
`, + `
`, + ` ${this.escapeHtml(prev.title)}`, + prev.date ? ` ${this.formatDate(prev.date)}` : '', + `
`, + `
`, + `
`, + ].join('\n') + : '
'; + + const nextPeek = next + ? [ + `
`, + ``, + `
`, + ` `, + ` newer →`, + `
`, + `
`, + ` ${this.escapeHtml(next.title)}`, + next.date ? ` ${this.formatDate(next.date)}` : '', + `
`, + `
`, + `
`, + ].join('\n') + : '
'; + const content = [ '
', this.renderBrandBar(), + '
', + prevPeek, '
', '
', `

${safeTitle}

`, metaBits.length > 0 ? ` ` : '', tagsHtml, '
', + '', '
', ` `, '
', + 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 - - - - - - - - - - - - - - - - - -
- - - - -
-
- - - -
-
-

Blog

-

Direct links to every post.

-
-
- - -
-
-
- - - \ 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 - - - - - - - - - - - - - - - - - -
- - - - -
-
- - - -
-
-

Markdown Formatting Test

- - -
-
- -
-
- - - \ 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 - - - - - - - - - - - - - - - - - -
- - - - -
-
- - - -
-
-

Building Interactive Node Graphs

- - -
-
- -
-
- - - \ 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 - - - - - - - - - - - - - - - - - -
- - - - -
-
- - - -
-
-

test d d d d

- - -
-
- -
-
- - - \ 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 - - - - - - - - - - - - - - - - - -
- - - - -
-
- - - -
-
-

Welcome to My 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; }