mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 10:46:03 +10:00
Compare commits
29 Commits
40fd8e8e42
...
0834d30e9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0834d30e9b | |||
| 52c196515e | |||
| 19a3b06682 | |||
| 868381c9c8 | |||
| 16f72f58ce | |||
| e165203a8b | |||
| 770cc37171 | |||
| 45774f4d4d | |||
| bfa7cfcb36 | |||
| d990adf193 | |||
| 6e1728d35e | |||
| 7007660a80 | |||
| ba36cdd05b | |||
| fabbb0539a | |||
| daef942927 | |||
| a16445dd78 | |||
| 35b049b833 | |||
| b8e357e76b | |||
| 924ebfa164 | |||
| ed797df9b4 | |||
| d65cadc2e5 | |||
| 01bac4c0bd | |||
| 4e4665c4ca | |||
| 628adc59ee | |||
| f56a1e9181 | |||
| 2625ecc55b | |||
| 715bf07eae | |||
| a9b65b1dba | |||
| 0f59de54bb |
300
build.js
300
build.js
@@ -1,5 +1,9 @@
|
||||
const fs = require('fs').promises;
|
||||
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
|
||||
@@ -35,11 +39,15 @@ 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: buildDir
|
||||
});
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(blogDir);
|
||||
@@ -85,9 +93,17 @@ async function processBlogPosts() {
|
||||
if (posts.length > 0) {
|
||||
await updateGraphWithBlogNodes(posts, graphPath);
|
||||
}
|
||||
|
||||
await staticBlogGenerator.generate(posts);
|
||||
console.log('✓ Generated static blog pages');
|
||||
await generateRssFeed(posts, buildDir);
|
||||
return posts;
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
console.log('⚠ No blog directory found, skipping blog processing');
|
||||
await staticBlogGenerator.generate([]);
|
||||
console.log('✓ Generated empty static blog index');
|
||||
return [];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -174,6 +190,56 @@ async function updateGraphWithBlogNodes(posts, graphPath) {
|
||||
console.log(`✓ Added ${blogNodes.length} blog node(s) to graph`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle all JavaScript files into a single bundle.js using esbuild
|
||||
*/
|
||||
async function bundleJavaScript() {
|
||||
const entryPointMain = path.join(__dirname, 'website', 'scripts', 'main.js');
|
||||
const entryPointBlog = path.join(__dirname, 'website', 'scripts', 'blogPage.js');
|
||||
const outdir = path.join(__dirname, 'build', 'scripts');
|
||||
const scriptsDir = path.join(__dirname, 'build', 'scripts');
|
||||
|
||||
try {
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPointMain, entryPointBlog],
|
||||
bundle: true,
|
||||
outdir: outdir,
|
||||
entryNames: '[name].bundle',
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
|
||||
console.log('✓ Bundled JavaScript with esbuild');
|
||||
|
||||
// Remove all individual JS files except bundle.js
|
||||
async function removeJsFiles(dir) {
|
||||
const items = await fs.readdir(dir);
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stats = await fs.stat(fullPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await removeJsFiles(fullPath);
|
||||
// Remove empty directory
|
||||
await fs.rmdir(fullPath);
|
||||
} else if (item.endsWith('.js') && !item.endsWith('.bundle.js')) {
|
||||
await fs.unlink(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await removeJsFiles(scriptsDir);
|
||||
console.log('✓ Removed individual JS files');
|
||||
} catch (error) {
|
||||
console.error('✗ JavaScript bundling failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy files recursively from website to build
|
||||
async function copyWebsiteFiles() {
|
||||
const websiteDir = path.join(__dirname, 'website');
|
||||
@@ -215,10 +281,236 @@ async function copyWebsiteFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minify CSS files in the build directory
|
||||
*/
|
||||
async function minifyCSS() {
|
||||
const stylesDir = path.join(__dirname, 'build', 'styles');
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(stylesDir);
|
||||
const cssFiles = files.filter(f => f.endsWith('.css'));
|
||||
|
||||
for (const file of cssFiles) {
|
||||
const filePath = path.join(stylesDir, file);
|
||||
const outputPath = filePath;
|
||||
|
||||
execSync(`npx cleancss -o "${outputPath}" "${filePath}"`, {
|
||||
cwd: __dirname,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✓ Minified ${cssFiles.length} CSS file(s)`);
|
||||
} catch (error) {
|
||||
console.error('✗ CSS minification failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update HTML files to use bundled JavaScript
|
||||
*/
|
||||
async function updateHtmlForBundle() {
|
||||
const buildDir = path.join(__dirname, 'build');
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateHtmlFile(filePath) {
|
||||
let html = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
html = html.replace(
|
||||
/<script type="module" src="scripts\/main\.js"><\/script>/,
|
||||
'<script type="module" src="scripts/main.bundle.js"></script>'
|
||||
);
|
||||
|
||||
html = html.replace(/src="\/scripts\/blogPage\.js"/g, 'src="/scripts/blogPage.bundle.js"');
|
||||
|
||||
html = html.replace(
|
||||
/[ \t]*<!-- NAV_LINKS -->/g,
|
||||
renderNavLinkItems(' ')
|
||||
);
|
||||
|
||||
await fs.writeFile(filePath, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function walkAndUpdateHtml(dir) {
|
||||
const items = await fs.readdir(dir);
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stats = await fs.stat(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
await walkAndUpdateHtml(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.endsWith('.html')) {
|
||||
await updateHtmlFile(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await walkAndUpdateHtml(buildDir);
|
||||
console.log('✓ Updated HTML to use JavaScript bundles');
|
||||
} catch (error) {
|
||||
console.error('✗ Failed to update HTML:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an RSS 2.0 feed from blog posts and writes it to build/rss.xml.
|
||||
*
|
||||
* @param {Array<{slug: string, title: string, date: string|null, author: string|null, content: string}>} posts
|
||||
* @param {string} buildDir
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function generateRssFeed(posts, buildDir) {
|
||||
const siteUrl = 'https://lit.ruv.wtf';
|
||||
const feedUrl = `${siteUrl}/rss.xml`;
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function xmlEscape(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips markdown syntax to produce plain-text for descriptions.
|
||||
*
|
||||
* @param {string} md
|
||||
* @returns {string}
|
||||
*/
|
||||
function mdToPlain(md) {
|
||||
return (md || '')
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, '')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\[([^\]]+)\]\(.*?\)/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||
.replace(/^[-*_]{3,}$/gm, '')
|
||||
.replace(/\n{2,}/g, ' ')
|
||||
.replace(/\n/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 400);
|
||||
}
|
||||
|
||||
const items = posts.map(post => {
|
||||
const url = `${siteUrl}/blog/${encodeURIComponent(post.slug)}/`;
|
||||
const pubDate = post.date ? new Date(post.date).toUTCString() : '';
|
||||
const description = xmlEscape(mdToPlain(post.content));
|
||||
return [
|
||||
' <item>',
|
||||
` <title>${xmlEscape(post.title || post.slug)}</title>`,
|
||||
` <link>${url}</link>`,
|
||||
` <guid isPermaLink="true">${url}</guid>`,
|
||||
pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
|
||||
post.author ? ` <author>${xmlEscape(post.author)}</author>` : '',
|
||||
` <description>${description}</description>`,
|
||||
' </item>',
|
||||
].filter(Boolean).join('\n');
|
||||
});
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
||||
' <channel>',
|
||||
' <title>lit.ruv.wtf</title>',
|
||||
` <link>${siteUrl}</link>`,
|
||||
' <description>Blog posts from lit.ruv.wtf</description>',
|
||||
' <language>en-us</language>',
|
||||
` <atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
|
||||
...items,
|
||||
' </channel>',
|
||||
'</rss>',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(path.join(buildDir, 'rss.xml'), xml, 'utf-8');
|
||||
console.log('✓ Generated RSS feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sitemap.xml from static routes and blog posts.
|
||||
*
|
||||
* @param {Array<{slug: string, date: string|null}>} posts
|
||||
* @param {string} buildDir
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function generateSitemap(posts, buildDir) {
|
||||
const siteUrl = 'https://lit.ruv.wtf';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* @param {string} loc
|
||||
* @param {{ lastmod?: string, priority?: string }} opts
|
||||
* @returns {string}
|
||||
*/
|
||||
function urlEntry(loc, { lastmod = today, priority = '0.80' } = {}) {
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${loc}</loc>`,
|
||||
` <lastmod>${lastmod}</lastmod>`,
|
||||
` <priority>${priority}</priority>`,
|
||||
' </url>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const staticEntries = [
|
||||
urlEntry(`${siteUrl}/`, { priority: '1.00' }),
|
||||
urlEntry(`${siteUrl}/blog.html`, { priority: '0.90' }),
|
||||
urlEntry(`${siteUrl}/docs/sitemap.xml`, { priority: '0.80' }),
|
||||
urlEntry(`${siteUrl}/materials/`, { priority: '0.80' }),
|
||||
];
|
||||
|
||||
const postEntries = posts.map(post => {
|
||||
const lastmod = post.date ? new Date(post.date).toISOString().slice(0, 10) : today;
|
||||
return urlEntry(`${siteUrl}/blog/${encodeURIComponent(post.slug)}/`, { lastmod, priority: '0.50' });
|
||||
});
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
|
||||
' xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9',
|
||||
' http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">',
|
||||
...staticEntries,
|
||||
...postEntries,
|
||||
'</urlset>',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(path.join(buildDir, 'sitemap.xml'), xml, 'utf-8');
|
||||
console.log(`✓ Generated sitemap with ${staticEntries.length + postEntries.length} URL(s)`);
|
||||
}
|
||||
|
||||
// Run the build
|
||||
async function build() {
|
||||
await processBlogPosts();
|
||||
const buildDir = path.join(__dirname, 'build');
|
||||
await copyWebsiteFiles();
|
||||
const posts = await processBlogPosts(buildDir);
|
||||
await generateSitemap(posts, buildDir);
|
||||
await bundleJavaScript();
|
||||
await minifyCSS();
|
||||
await updateHtmlForBundle();
|
||||
|
||||
// Report final sizes
|
||||
console.log('\n=== Build Summary ===');
|
||||
const mainBundleStats = await fs.stat(path.join(__dirname, 'build', 'scripts', 'main.bundle.js'));
|
||||
const blogBundleStats = await fs.stat(path.join(__dirname, 'build', 'scripts', 'blogPage.bundle.js'));
|
||||
const cssStats = await fs.stat(path.join(__dirname, 'build', 'styles', 'main.css'));
|
||||
console.log(`main.bundle.js: ${(mainBundleStats.size / 1024).toFixed(2)} KB`);
|
||||
console.log(`blogPage.bundle.js: ${(blogBundleStats.size / 1024).toFixed(2)} KB`);
|
||||
console.log(`main.css: ${(cssStats.size / 1024).toFixed(2)} KB`);
|
||||
console.log('=====================\n');
|
||||
}
|
||||
|
||||
build();
|
||||
|
||||
3822
package-lock.json
generated
3822
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -5,7 +5,8 @@
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
"start": "python3 -m http.server 8000",
|
||||
"start-alt": "python -m SimpleHTTPServer 8000"
|
||||
"start-alt": "python -m SimpleHTTPServer 8000",
|
||||
"blog-editor": "node tools/blog-editor.js"
|
||||
},
|
||||
"keywords": [
|
||||
"terminal",
|
||||
@@ -27,5 +28,10 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/litruv/lit.ruv.wtf"
|
||||
},
|
||||
"homepage": "https://lit.ruv.wtf"
|
||||
"homepage": "https://lit.ruv.wtf",
|
||||
"devDependencies": {
|
||||
"@milkdown/crepe": "^7.20.0",
|
||||
"clean-css-cli": "^5.6.3",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
}
|
||||
|
||||
331
tools/StaticBlogGenerator.js
Normal file
331
tools/StaticBlogGenerator.js
Normal file
@@ -0,0 +1,331 @@
|
||||
'use strict';
|
||||
|
||||
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.
|
||||
*/
|
||||
class StaticBlogGenerator {
|
||||
/**
|
||||
* @param {{ siteRootDir: string }} options
|
||||
*/
|
||||
constructor(options) {
|
||||
/** @type {string} */
|
||||
this.siteRootDir = options.siteRootDir;
|
||||
/** @type {string} */
|
||||
this.blogRootDir = path.join(this.siteRootDir, 'blog');
|
||||
/** @type {string} */
|
||||
this.blogIndexPath = path.join(this.siteRootDir, 'blog.html');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates blog index and post pages.
|
||||
*
|
||||
* @param {Array<{slug: string, title: string, date: string | null, author: string | null, tags?: string[], content: string}>} posts
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async generate(posts) {
|
||||
await fs.rm(this.blogRootDir, { recursive: true, force: true });
|
||||
await fs.mkdir(this.blogRootDir, { recursive: true });
|
||||
|
||||
const indexHtml = this.renderIndexPage(posts);
|
||||
await fs.writeFile(this.blogIndexPath, indexHtml, 'utf-8');
|
||||
|
||||
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, prev, next);
|
||||
await fs.writeFile(path.join(postDir, 'index.html'), postHtml, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{slug: string, title: string, date: string | null, author: string | null, tags?: string[]}>} posts
|
||||
* @returns {string}
|
||||
*/
|
||||
renderIndexPage(posts) {
|
||||
const cardsHtml = posts.map((post) => {
|
||||
const dateLabel = post.date ? this.formatDate(post.date) : 'Undated';
|
||||
const authorLabel = post.author ? this.escapeHtml(post.author) : 'Unknown author';
|
||||
const safeTitle = this.escapeHtml(post.title || post.slug);
|
||||
const safeSlug = encodeURIComponent(post.slug);
|
||||
const tags = Array.isArray(post.tags) ? post.tags : [];
|
||||
const tagsHtml = tags.length > 0
|
||||
? `<div class="blog-index-tags">${tags.map((tag) => `<span class="blog-tag">${this.escapeHtml(tag)}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
return [
|
||||
'<article class="blog-index-item">',
|
||||
` <h2 class="blog-index-item-title"><a href="/blog/${safeSlug}/">${safeTitle}</a></h2>`,
|
||||
` <p class="blog-index-item-meta">${dateLabel} <span aria-hidden="true">-</span> ${authorLabel}</p>`,
|
||||
tagsHtml,
|
||||
'</article>'
|
||||
].join('\n');
|
||||
}).join('\n');
|
||||
|
||||
const content = [
|
||||
'<main class="blog-shell">',
|
||||
this.renderBrandBar(),
|
||||
'<section class="blog-card" aria-labelledby="blog-index-title">',
|
||||
' <header class="blog-post-header">',
|
||||
' <h1 id="blog-index-title" class="blog-post-title">Blog</h1>',
|
||||
' <p class="blog-index-subtitle">Direct links to every post.</p>',
|
||||
' </header>',
|
||||
` <div class="blog-index-list">${cardsHtml || '<p class="blog-empty">No posts yet.</p>'}</div>`,
|
||||
'</section>',
|
||||
'</main>'
|
||||
].join('\n');
|
||||
|
||||
return this.renderPageTemplate('Blog', content, '/blog.html');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a plain-text description from the first paragraph of markdown content.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {string}
|
||||
*/
|
||||
extractDescription(content) {
|
||||
const lines = (content || '').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!') || trimmed.startsWith('>') || /^[-*_]{3,}$/.test(trimmed) || trimmed.startsWith('```')) continue;
|
||||
const plain = trimmed.replace(/[*_`\[\]]/g, '').replace(/!?\[.*?\]\(.*?\)/g, '').trim();
|
||||
if (plain.length > 20) return plain.slice(0, 400);
|
||||
}
|
||||
return 'Read this post on lit.ruv.wtf';
|
||||
}
|
||||
|
||||
/**
|
||||
* @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, 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;
|
||||
const tags = Array.isArray(post.tags) ? post.tags : [];
|
||||
|
||||
const metaBits = [];
|
||||
if (dateLabel) metaBits.push(`<span>${dateLabel}</span>`);
|
||||
if (authorLabel) metaBits.push(`<span>${authorLabel}</span>`);
|
||||
|
||||
const tagsHtml = tags.length > 0
|
||||
? `<div class="blog-post-tags">${tags.map((tag) => `<span class="blog-tag">${this.escapeHtml(tag)}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const markdownJson = JSON.stringify(post.content || '').replace(/<\//g, '<\\/');
|
||||
|
||||
const prevPeek = prev
|
||||
? [
|
||||
`<div class="blog-post-peek-wrapper blog-post-peek-wrapper--prev">`,
|
||||
`<a class="blog-post-peek blog-post-peek--prev" href="/blog/${encodeURIComponent(prev.slug)}/" aria-label="Older post: ${this.escapeHtml(prev.title)}">`,
|
||||
` <div class="blog-post-peek-header">`,
|
||||
` <span class="blog-post-peek-direction">← older</span>`,
|
||||
` <span class="blog-post-peek-pin" data-peek-pin="prev-out"></span>`,
|
||||
` </div>`,
|
||||
` <div class="blog-post-peek-body">`,
|
||||
` <span class="blog-post-peek-title">${this.escapeHtml(prev.title)}</span>`,
|
||||
prev.date ? ` <span class="blog-post-peek-date">${this.formatDate(prev.date)}</span>` : '',
|
||||
` </div>`,
|
||||
`</a>`,
|
||||
`</div>`,
|
||||
].join('\n')
|
||||
: '<div class="blog-post-peek-wrapper blog-post-peek-wrapper--prev"></div>';
|
||||
|
||||
const nextPeek = next
|
||||
? [
|
||||
`<div class="blog-post-peek-wrapper blog-post-peek-wrapper--next">`,
|
||||
`<a class="blog-post-peek blog-post-peek--next" href="/blog/${encodeURIComponent(next.slug)}/" aria-label="Newer post: ${this.escapeHtml(next.title)}">`,
|
||||
` <div class="blog-post-peek-header">`,
|
||||
` <span class="blog-post-peek-pin" data-peek-pin="next-in"></span>`,
|
||||
` <span class="blog-post-peek-direction" style="text-align:right">newer →</span>`,
|
||||
` </div>`,
|
||||
` <div class="blog-post-peek-body">`,
|
||||
` <span class="blog-post-peek-title">${this.escapeHtml(next.title)}</span>`,
|
||||
next.date ? ` <span class="blog-post-peek-date">${this.formatDate(next.date)}</span>` : '',
|
||||
` </div>`,
|
||||
`</a>`,
|
||||
`</div>`,
|
||||
].join('\n')
|
||||
: '<div class="blog-post-peek-wrapper blog-post-peek-wrapper--next"></div>';
|
||||
|
||||
const content = [
|
||||
'<main class="blog-shell">',
|
||||
this.renderBrandBar(),
|
||||
'<div class="blog-post-layout">',
|
||||
prevPeek,
|
||||
'<article class="blog-card" aria-labelledby="blog-post-title">',
|
||||
' <header class="blog-post-header">',
|
||||
` <h1 id="blog-post-title" class="blog-post-title">${safeTitle}</h1>`,
|
||||
metaBits.length > 0 ? ` <p class="blog-post-meta">${metaBits.join('<span aria-hidden="true">-</span>')}</p>` : '',
|
||||
tagsHtml,
|
||||
' </header>',
|
||||
'<div class="md-hr" role="separator"><span class="md-hr-line"></span><span class="md-hr-arrow"></span></div>',
|
||||
' <section class="blog-post-content" data-blog-post-content></section>',
|
||||
` <script id="blogPostMarkdown" type="application/json">${markdownJson}</script>`,
|
||||
'</article>',
|
||||
nextPeek,
|
||||
'<svg class="blog-post-splines" aria-hidden="true"></svg>',
|
||||
'</div>',
|
||||
'</main>'
|
||||
].join('\n');
|
||||
|
||||
const canonicalUrl = `https://lit.ruv.wtf/blog/${encodeURIComponent(post.slug)}/`;
|
||||
return this.renderPageTemplate(`${safeTitle} - Blog`, content, `/blog/${encodeURIComponent(post.slug)}/`, {
|
||||
description: this.extractDescription(post.content),
|
||||
ogTitle: safeTitle,
|
||||
ogType: 'article',
|
||||
ogUrl: canonicalUrl,
|
||||
articlePublishedTime: post.date || null,
|
||||
articleAuthor: post.author || null,
|
||||
articleTags: tags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @param {string} bodyHtml
|
||||
* @param {string} canonicalPath
|
||||
* @returns {string}
|
||||
*/
|
||||
renderPageTemplate(title, bodyHtml, canonicalPath, og = {}) {
|
||||
const escapedTitle = this.escapeHtml(title);
|
||||
const canonicalUrl = `https://lit.ruv.wtf${canonicalPath}`;
|
||||
const description = this.escapeHtml(og.description || 'lit.ruv.wtf blog posts.');
|
||||
const ogTitle = this.escapeHtml(og.ogTitle || title);
|
||||
const ogType = og.ogType || 'website';
|
||||
const ogUrl = og.ogUrl || canonicalUrl;
|
||||
|
||||
const ogImage = og.ogImage || 'https://lit.ruv.wtf/logos/512px.png';
|
||||
const ogMeta = [
|
||||
` <meta property="og:title" content="${ogTitle}">`,
|
||||
` <meta property="og:type" content="${ogType}">`,
|
||||
` <meta property="og:url" content="${ogUrl}">`,
|
||||
` <meta property="og:description" content="${description}">`,
|
||||
` <meta property="og:image" content="${ogImage}">`,
|
||||
` <meta property="og:site_name" content="lit.ruv.wtf">`,
|
||||
` <meta name="twitter:card" content="summary">`,
|
||||
` <meta name="twitter:title" content="${ogTitle}">`,
|
||||
` <meta name="twitter:description" content="${description}">`,
|
||||
og.articlePublishedTime ? ` <meta property="article:published_time" content="${og.articlePublishedTime}">` : '',
|
||||
og.articleAuthor ? ` <meta property="article:author" content="${this.escapeHtml(og.articleAuthor)}">` : '',
|
||||
...(og.articleTags || []).map(t => ` <meta property="article:tag" content="${this.escapeHtml(t)}">`,),
|
||||
].filter(Boolean);
|
||||
|
||||
return [
|
||||
'<!DOCTYPE html>',
|
||||
'<html lang="en">',
|
||||
'<head>',
|
||||
' <meta charset="UTF-8">',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
|
||||
' <base href="/">',
|
||||
` <title>${escapedTitle}</title>`,
|
||||
` <meta name="description" content="${description}">`,
|
||||
` <link rel="canonical" href="${canonicalUrl}">`,
|
||||
...ogMeta,
|
||||
` <link rel="alternate" type="application/rss+xml" title="lit.ruv.wtf RSS" href="/rss.xml">`,
|
||||
' <link rel="icon" type="image/png" sizes="32x32" href="/logos/32px.png">',
|
||||
' <link rel="icon" type="image/png" sizes="64x64" href="/logos/64px.png">',
|
||||
' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">',
|
||||
' <link rel="stylesheet" href="/styles/main.css">',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>',
|
||||
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>',
|
||||
'</head>',
|
||||
'<body class="blog-page">',
|
||||
'<nav class="quick-links" aria-label="Primary">',
|
||||
this.renderHeaderLinks(),
|
||||
' <a class="quick-link rss-link" href="/rss.xml" aria-label="RSS feed" title="RSS feed">',
|
||||
' <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/></svg>',
|
||||
' RSS',
|
||||
' </a>',
|
||||
'</nav>',
|
||||
this.renderScrollTopBar(),
|
||||
bodyHtml,
|
||||
' <script type="module" src="/scripts/blogPage.js"></script>',
|
||||
'</body>',
|
||||
'</html>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
renderHeaderLinks() {
|
||||
return renderNavLinkItems(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
renderScrollTopBar() {
|
||||
return [
|
||||
'<header class="blog-scroll-topbar" data-blog-scroll-topbar>',
|
||||
' <a class="blog-scroll-logo-link" href="/" aria-label="Back to main site">',
|
||||
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-scroll-logo" />',
|
||||
' </a>',
|
||||
' <nav class="blog-scroll-links" aria-label="Scrolled navigation">',
|
||||
this.renderHeaderLinks(),
|
||||
' </nav>',
|
||||
'</header>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
renderBrandBar() {
|
||||
return [
|
||||
'<a class="blog-logo-link" href="/" aria-label="Back to main site">',
|
||||
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-logo" />',
|
||||
'</a>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dateInput
|
||||
* @returns {string}
|
||||
*/
|
||||
formatDate(dateInput) {
|
||||
const parsed = new Date(dateInput);
|
||||
if (Number.isNaN(parsed.valueOf())) {
|
||||
return this.escapeHtml(dateInput);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
StaticBlogGenerator
|
||||
};
|
||||
4
tools/blogeditor/.vscodeignore
Normal file
4
tools/blogeditor/.vscodeignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Exclude source/build artifacts — keep only what's needed in the VSIX
|
||||
build-dist.js
|
||||
*.vsix
|
||||
.gitignore
|
||||
BIN
tools/blogeditor/blog-editor-0.1.12.vsix
Normal file
BIN
tools/blogeditor/blog-editor-0.1.12.vsix
Normal file
Binary file not shown.
83
tools/blogeditor/build-dist.js
Normal file
83
tools/blogeditor/build-dist.js
Normal file
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pre-builds the Milkdown Crepe JS + CSS bundles into .vscode/dist/
|
||||
* Run this before packaging the VSIX: node .vscode/build-dist.js
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Resolve symlinks so node_modules lookup works even in junction'd paths
|
||||
const ROOT = fs.realpathSync(__dirname);
|
||||
const DIST_DIR = path.join(ROOT, 'dist');
|
||||
|
||||
/**
|
||||
* Walks up from startDir to find node_modules containing a given package.
|
||||
* Returns null if not found (never throws).
|
||||
* @param {string} pkg
|
||||
* @param {string} startDir
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function resolveFromNearestNodeModules(pkg, startDir) {
|
||||
let dir = startDir;
|
||||
while (true) {
|
||||
const candidate = path.join(dir, 'node_modules', pkg);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from __dirname until we find node_modules with esbuild.
|
||||
// Also tries process.cwd() in case the script is in a stub folder without node_modules.
|
||||
const esbuildEntry = resolveFromNearestNodeModules('esbuild', ROOT)
|
||||
?? resolveFromNearestNodeModules('esbuild', process.cwd());
|
||||
if (!esbuildEntry) throw new Error('Cannot find esbuild. Run this from the project root that contains node_modules.');
|
||||
const esbuild = require(path.join(esbuildEntry, 'lib', 'main.js'));
|
||||
const crepeLib = resolveFromNearestNodeModules('@milkdown/crepe', ROOT)
|
||||
?? resolveFromNearestNodeModules('@milkdown/crepe', process.cwd());
|
||||
const CREPE_DIR = path.join(crepeLib, 'lib', 'theme');
|
||||
// Also need the root for esbuild resolveDir (must contain node_modules/@milkdown)
|
||||
const ROOT_DIR = path.dirname(path.dirname(esbuildEntry));
|
||||
|
||||
const FONT_LOADERS = {
|
||||
'.woff2': 'dataurl',
|
||||
'.woff': 'dataurl',
|
||||
'.ttf': 'dataurl',
|
||||
'.eot': 'dataurl',
|
||||
'.svg': 'dataurl',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||
|
||||
console.log('⏳ Bundling Milkdown JS…');
|
||||
const jsResult = await esbuild.build({
|
||||
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: ROOT_DIR, loader: 'js' },
|
||||
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
|
||||
});
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
|
||||
console.log(`✓ milkdown.js ${(jsResult.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||
|
||||
console.log('⏳ Bundling common CSS…');
|
||||
const cssCommon = await esbuild.build({
|
||||
entryPoints: [path.join(CREPE_DIR, 'common', 'style.css')],
|
||||
bundle: true, write: false, loader: FONT_LOADERS,
|
||||
});
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
|
||||
console.log(`✓ milkdown-common.css ${(cssCommon.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||
|
||||
console.log('⏳ Bundling theme CSS…');
|
||||
const cssTheme = await esbuild.build({
|
||||
entryPoints: [path.join(CREPE_DIR, 'frame-dark', 'style.css')],
|
||||
bundle: true, write: false, loader: FONT_LOADERS,
|
||||
});
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
||||
console.log(`✓ milkdown-theme.css ${(cssTheme.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||
|
||||
console.log('\n✓ dist/ ready — run: npx @vscode/vsce package --out blog-editor.vsix --no-dependencies');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
768
tools/blogeditor/editor.html
Normal file
768
tools/blogeditor/editor.html
Normal file
@@ -0,0 +1,768 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none';
|
||||
script-src 'nonce-{{NONCE}}' {{CSP_SOURCE}};
|
||||
connect-src {{CSP_SOURCE}} blob: https:;
|
||||
style-src {{CSP_SOURCE}} 'unsafe-inline';
|
||||
img-src {{CSP_SOURCE}} data: blob: https:;
|
||||
font-src {{CSP_SOURCE}} data:;">
|
||||
<title>Blog Editor</title>
|
||||
|
||||
<link rel="stylesheet" href="{{COMMON_CSS}}">
|
||||
<link rel="stylesheet" href="{{THEME_CSS}}">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #11111b; --mantle: #181825; --surface0: #313244;
|
||||
--surface1: #45475a; --surface2: #585b70; --text: #cdd6f4;
|
||||
--sub0: #a6adc8; --sub1: #bac2de; --blue: #89b4fa;
|
||||
--green: #a6e3a1; --red: #f38ba8; --peach: #fab387;
|
||||
--mauve: #cba6f7; --sky: #89dceb; --teal: #94e2d5;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────── */
|
||||
#sidebar {
|
||||
background: var(--mantle);
|
||||
border-right: 1px solid var(--surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--surface0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--sub0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
#btn-new {
|
||||
background: var(--surface0);
|
||||
color: var(--blue);
|
||||
border: 1px solid var(--surface1);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
#btn-new:hover { background: var(--surface1); }
|
||||
|
||||
#post-list {
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
}
|
||||
#post-list li {
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 1px 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--sub1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#post-list li:hover { background: var(--surface0); color: var(--text); }
|
||||
#post-list li.active { background: var(--surface0); color: var(--blue); font-weight: 600; }
|
||||
|
||||
/* ── Main ────────────────────────────────────────────────────────────── */
|
||||
#main {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#frontmatter {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--mantle);
|
||||
border-bottom: 1px solid var(--surface0);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.frontmatter-fields {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.fm-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
background: var(--surface0);
|
||||
border: 1px solid var(--surface1);
|
||||
border-radius: 6px;
|
||||
padding: 0 8px;
|
||||
height: 34px;
|
||||
}
|
||||
.fm-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--sub0);
|
||||
}
|
||||
.fm-icon {
|
||||
width: 1.1em;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
cursor: help;
|
||||
}
|
||||
#frontmatter input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.fm-field:focus-within { border-color: var(--blue); }
|
||||
.fm-field.slug-field { flex: 1 1 170px; min-width: 170px; }
|
||||
.fm-field.title-field { flex: 2.2 1 280px; min-width: 220px; }
|
||||
.fm-field.date-field { flex: 0.8 1 150px; min-width: 150px; }
|
||||
.fm-field.author-field { flex: 1.1 1 180px; min-width: 160px; }
|
||||
.fm-field.tags-field { flex: 1.8 1 240px; min-width: 200px; }
|
||||
#fm-slug { font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 0.75rem; color: var(--sub0); }
|
||||
#fm-date { color-scheme: dark; }
|
||||
|
||||
/* ── Editor area ─────────────────────────────────────────────────────── */
|
||||
#editor-wrap {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Milkdown host ───────────────────────────────────────────────────── */
|
||||
.milkdown-root, .milkdown {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--bg) !important;
|
||||
font-family: system-ui, -apple-system, sans-serif !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.milkdown .editor {
|
||||
padding: 40px 56px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
min-height: calc(100% - 1px);
|
||||
font-family: system-ui, -apple-system, sans-serif !important;
|
||||
font-size: 14px;
|
||||
color: var(--sub1);
|
||||
caret-color: var(--mauve);
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
.milkdown .editor *:not(pre):not(code):not(pre *):not(code *):not(.cm-editor):not(.cm-editor *) {
|
||||
font-family: system-ui, -apple-system, sans-serif !important;
|
||||
}
|
||||
|
||||
/* ── Headings ─────────────────────────────────────────────────────────── */
|
||||
.milkdown .editor h1,
|
||||
.milkdown .editor h2,
|
||||
.milkdown .editor h3,
|
||||
.milkdown .editor h4,
|
||||
.milkdown .editor h5,
|
||||
.milkdown .editor h6 {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
margin-top: 0.9em;
|
||||
margin-bottom: 0.3em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.milkdown .editor h1 { font-size: 1.75rem; }
|
||||
.milkdown .editor h2 { font-size: 1.25rem; }
|
||||
.milkdown .editor h3 { font-size: 1.05rem; }
|
||||
.milkdown .editor h4 { font-size: 0.95rem; }
|
||||
.milkdown .editor h5 { font-size: 0.88rem; }
|
||||
.milkdown .editor h6 { font-size: 0.82rem; }
|
||||
|
||||
/* ── Body text ────────────────────────────────────────────────────────── */
|
||||
.milkdown .editor p { margin: 0.45em 0; line-height: 1.6; }
|
||||
|
||||
/* ── Links ────────────────────────────────────────────────────────────── */
|
||||
.milkdown .editor a { color: var(--blue); text-decoration: none; }
|
||||
.milkdown .editor a:hover { text-decoration: underline; opacity: 0.85; }
|
||||
|
||||
/* ── Strong / em ──────────────────────────────────────────────────────── */
|
||||
.milkdown .editor strong { color: var(--text); font-weight: 700; }
|
||||
.milkdown .editor em { color: var(--sub1); font-style: italic; }
|
||||
|
||||
/* ── Lists ────────────────────────────────────────────────────────────── */
|
||||
.milkdown .editor ul,
|
||||
.milkdown .editor ol { margin: 0.3em 0 0.3em 1.3em; padding: 0; }
|
||||
.milkdown .editor li { margin: 0.18em 0; line-height: 1.6; }
|
||||
|
||||
/* ── Blockquote ───────────────────────────────────────────────────────── */
|
||||
.milkdown .editor blockquote {
|
||||
border-left: 3px solid rgba(250, 179, 135, 0.5);
|
||||
padding: 0.2em 0.7em;
|
||||
margin: 0.5em 0;
|
||||
color: rgba(186, 194, 222, 0.7);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Inline code ──────────────────────────────────────────────────────── */
|
||||
.milkdown .editor :not(pre) > code {
|
||||
font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace !important;
|
||||
font-size: 0.82em;
|
||||
background: rgba(49, 50, 68, 0.6);
|
||||
border-radius: 3px;
|
||||
padding: 0.1em 0.35em;
|
||||
color: var(--sky);
|
||||
}
|
||||
|
||||
/* ── Code blocks ──────────────────────────────────────────────────────── */
|
||||
.milkdown .editor pre,
|
||||
.milkdown .editor code,
|
||||
.milkdown .editor pre *,
|
||||
.milkdown .editor code *,
|
||||
.milkdown .editor .cm-editor,
|
||||
.milkdown .editor .cm-editor * {
|
||||
font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace !important;
|
||||
}
|
||||
.milkdown .editor pre {
|
||||
background: rgba(17, 17, 27, 0.7);
|
||||
border: 1px solid var(--surface1);
|
||||
border-radius: 6px;
|
||||
padding: 0.6em 0.9em;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
font-size: 0.82em;
|
||||
color: var(--sky);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.milkdown .editor pre code { background: none; padding: 0; border-radius: 0; color: inherit; }
|
||||
|
||||
/* ── Horizontal rule ──────────────────────────────────────────────────── */
|
||||
.milkdown .editor hr { border: none; border-top: 1px solid var(--surface1); margin: 0.8em 0; }
|
||||
|
||||
/* ── Images ───────────────────────────────────────────────────────────── */
|
||||
.milkdown .editor img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 0.6em auto; }
|
||||
|
||||
/* ── Tables ───────────────────────────────────────────────────────────── */
|
||||
.milkdown .editor table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.88em; }
|
||||
.milkdown .editor th { background: var(--surface0); color: var(--text); font-weight: 600; padding: 0.4em 0.7em; border: 1px solid var(--surface1); text-align: left; }
|
||||
.milkdown .editor td { padding: 0.35em 0.7em; border: 1px solid var(--surface1); color: var(--sub1); }
|
||||
.milkdown .editor tr:nth-child(even) td { background: rgba(49, 50, 68, 0.25); }
|
||||
|
||||
/* ── Milkdown toolbar chrome ──────────────────────────────────────────── */
|
||||
.milkdown-menu { background: var(--mantle) !important; border-bottom: 1px solid var(--surface0) !important; }
|
||||
.milkdown-menu button { color: var(--sub0) !important; }
|
||||
.milkdown-menu button:hover { color: var(--text) !important; background: var(--surface0) !important; }
|
||||
.milkdown-menu button.active { color: var(--mauve) !important; }
|
||||
|
||||
/* ── Status bar ──────────────────────────────────────────────────────── */
|
||||
#statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 12px;
|
||||
background: var(--mantle);
|
||||
border-top: 1px solid var(--surface0);
|
||||
font-size: 0.75rem;
|
||||
color: var(--sub0);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#status.dirty { color: var(--peach); }
|
||||
#status.saved { color: var(--green); }
|
||||
#btn-save {
|
||||
background: transparent;
|
||||
color: var(--blue);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
#btn-save:hover { background: rgba(137, 180, 250, 0.12); opacity: 1; }
|
||||
#btn-save:disabled { background: transparent; color: var(--sub0); cursor: default; opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main">
|
||||
<div id="frontmatter">
|
||||
<div class="frontmatter-fields">
|
||||
<label class="fm-field slug-field" title="Post slug (filename)">
|
||||
<span class="fm-label" title="Post slug (filename)"><span class="fm-icon" title="Post slug (filename)">#</span></span>
|
||||
<input id="fm-slug" placeholder="Slug" title="Post slug (filename)" aria-label="Slug" />
|
||||
</label>
|
||||
<label class="fm-field title-field" title="Post title">
|
||||
<span class="fm-label" title="Post title"><span class="fm-icon" title="Post title">📝</span></span>
|
||||
<input id="fm-title" placeholder="Title" title="Post title" aria-label="Title" />
|
||||
</label>
|
||||
<label class="fm-field date-field" title="Publish date">
|
||||
<span class="fm-label" title="Publish date"><span class="fm-icon" title="Publish date">🗓</span></span>
|
||||
<input id="fm-date" type="date" title="Publish date" aria-label="Date" />
|
||||
</label>
|
||||
<label class="fm-field author-field" title="Post author">
|
||||
<span class="fm-label" title="Post author"><span class="fm-icon" title="Post author">👤</span></span>
|
||||
<input id="fm-author" placeholder="Author" title="Post author" aria-label="Author" />
|
||||
</label>
|
||||
<label class="fm-field tags-field" title="Comma-separated tags">
|
||||
<span class="fm-label" title="Comma-separated tags"><span class="fm-icon" title="Comma-separated tags">🏷</span></span>
|
||||
<input id="fm-tags" placeholder="Tags" title="Comma-separated tags" aria-label="Tags" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editor-wrap"></div>
|
||||
<div id="statusbar">
|
||||
<span id="status">No file open</span>
|
||||
<div class="status-actions">
|
||||
<button id="btn-save" disabled title="Save (Ctrl+S)" aria-label="Save">💾</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" nonce="{{NONCE}}">
|
||||
import * as Milkdown from '{{MILKDOWN_JS}}';
|
||||
|
||||
const Crepe = Milkdown.Crepe;
|
||||
const imageBlockFeature = Milkdown.CrepeFeature?.ImageBlock ?? 'image-block';
|
||||
|
||||
const vscode = acquireVsCodeApi();
|
||||
const editorWrap = /** @type {HTMLDivElement} */ (document.getElementById('editor-wrap'));
|
||||
const status = /** @type {HTMLSpanElement} */ (document.getElementById('status'));
|
||||
const btnSave = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save'));
|
||||
const fmTitle = /** @type {HTMLInputElement} */ (document.getElementById('fm-title'));
|
||||
const fmSlug = /** @type {HTMLInputElement} */ (document.getElementById('fm-slug'));
|
||||
const fmDate = /** @type {HTMLInputElement} */ (document.getElementById('fm-date'));
|
||||
const fmAuthor = /** @type {HTMLInputElement} */ (document.getElementById('fm-author'));
|
||||
const fmTags = /** @type {HTMLInputElement} */ (document.getElementById('fm-tags'));
|
||||
|
||||
/** @type {string | null} */ let currentSlug = null;
|
||||
/** @type {import('{{MILKDOWN_JS}}').Crepe | null} */ let crepe = null;
|
||||
let dirty = false;
|
||||
/** @type {((slug: string) => void) | null} */ let _pendingRenameResolve = null;
|
||||
|
||||
/** @param {string | undefined} slug @returns {boolean} */
|
||||
function isValidSlug(slug) {
|
||||
return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(slug) && slug.length <= 100;
|
||||
}
|
||||
|
||||
// -- Image handling -------------------------------------------------------
|
||||
// Relative image paths are rewritten to webview resource URIs when a post
|
||||
// is loaded (imageMap: webviewUri -> relativePath). Pasted/dropped images
|
||||
// arrive as blob: URLs with no mapping. On save we fetch each unknown blob,
|
||||
// upload it to the extension, await mediaSaved, then write clean paths.
|
||||
|
||||
/** webviewUri -> relativePath */
|
||||
const webviewToRelative = new Map();
|
||||
/** blobUrl -> relativePath, populated after mediaSaved */
|
||||
const blobToRelative = new Map();
|
||||
/** blobUrl -> Promise<void>, de-dupes concurrent saves for same blob */
|
||||
const _blobSavePromises = new Map();
|
||||
/** blobUrl -> resolve(), resolved when mediaSaved arrives */
|
||||
const _blobSaveResolvers = new Map();
|
||||
/** uploadId -> resolve(relativePath) */
|
||||
const _uploadResolvers = new Map();
|
||||
|
||||
/**
|
||||
* Splits markdown image target into src and optional title segment.
|
||||
* @param {string} target
|
||||
* @returns {{ src: string, suffix: string }}
|
||||
*/
|
||||
function splitImageTarget(target) {
|
||||
const trimmed = target.trim();
|
||||
const m = trimmed.match(/^(\S+)(\s+["'][\s\S]*["'])?$/);
|
||||
if (!m) return { src: trimmed, suffix: '' };
|
||||
return { src: m[1], suffix: m[2] ?? '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a blob URL to the extension to save to media/.
|
||||
* Returns a Promise that resolves once mediaSaved is received.
|
||||
* @param {string} blobUrl
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function saveNewMediaAndWait(blobUrl) {
|
||||
if (blobToRelative.has(blobUrl)) return Promise.resolve();
|
||||
if (_blobSavePromises.has(blobUrl)) return /** @type {Promise<void>} */ (_blobSavePromises.get(blobUrl));
|
||||
const p = new Promise(async (resolve) => {
|
||||
_blobSaveResolvers.set(blobUrl, resolve);
|
||||
try {
|
||||
const resp = await fetch(blobUrl);
|
||||
const blob = await resp.blob();
|
||||
const ext = (blob.type.split('/')[1] ?? 'png').split('+')[0].replace(/[^a-z0-9]/gi, '') || 'png';
|
||||
const name = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
let b64 = '';
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
b64 += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||
}
|
||||
send('saveMedia', { blobUrl, dataBase64: btoa(b64), mimeType: blob.type, filename: name });
|
||||
} catch (e) {
|
||||
console.error('[Blog Editor] Failed to upload pasted image:', e);
|
||||
_blobSaveResolvers.delete(blobUrl);
|
||||
_blobSavePromises.delete(blobUrl);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
_blobSavePromises.set(blobUrl, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a File and resolves with a persistent relative path.
|
||||
* Used by Milkdown image uploads so markdown never stores blob: URLs.
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function uploadImageFile(file) {
|
||||
console.log('[Blog Editor] uploadImageFile called:', file.name, file.type);
|
||||
const extFromType = (file.type.split('/')[1] ?? '').split('+')[0].replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
const extFromName = (file.name.split('.').pop() ?? '').replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
const ext = extFromType || extFromName || 'png';
|
||||
const uploadId = `upload:${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let b64 = '';
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
b64 += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||
}
|
||||
|
||||
const filename = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
|
||||
console.log('[Blog Editor] Sending saveMedia:', filename);
|
||||
send('saveMedia', { blobUrl: uploadId, dataBase64: btoa(b64), mimeType: file.type, filename });
|
||||
return new Promise(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('[Blog Editor] uploadImageFile timeout - no response after 10s:', uploadId);
|
||||
resolve(`data/blog/media/${filename}`);
|
||||
}, 10000);
|
||||
_uploadResolvers.set(uploadId, (path) => {
|
||||
clearTimeout(timeout);
|
||||
console.log('[Blog Editor] uploadImageFile resolved:', path);
|
||||
resolve(path);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts any remaining blob: URLs to file paths by uploading them.
|
||||
* This is a safety fallback for blobs that Crepe inserted before onUpload was called.
|
||||
* @param {string} markdown
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function uploadRemainingBlobs(markdown) {
|
||||
const blobMatches = [...markdown.matchAll(/!\[([^\]]*)\]\((blob:[^)]+)\)/g)];
|
||||
if (blobMatches.length === 0) return markdown;
|
||||
console.warn(`[Blog Editor] Found ${blobMatches.length} remaining blob URLs - uploading them`);
|
||||
const uploads = blobMatches.map(async m => {
|
||||
const blobUrl = m[2];
|
||||
if (blobToRelative.has(blobUrl)) return { blobUrl, relPath: blobToRelative.get(blobUrl) };
|
||||
try {
|
||||
const resp = await fetch(blobUrl);
|
||||
const blob = await resp.blob();
|
||||
const ext = (blob.type.split('/')[1] ?? 'png').split('+')[0].replace(/[^a-z0-9]/gi, '') || 'png';
|
||||
const filename = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
let b64 = '';
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
b64 += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||
}
|
||||
console.log('[Blog Editor] Uploading leftover blob:', filename);
|
||||
return await new Promise(resolve => {
|
||||
send('saveMedia', { blobUrl, dataBase64: btoa(b64), mimeType: blob.type, filename });
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('[Blog Editor] Leftover blob upload timeout:', blobUrl);
|
||||
resolve({ blobUrl, relPath: `data/blog/media/${filename}` });
|
||||
}, 5000);
|
||||
const checkInterval = setInterval(() => {
|
||||
if (blobToRelative.has(blobUrl)) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(checkInterval);
|
||||
resolve({ blobUrl, relPath: blobToRelative.get(blobUrl) });
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Blog Editor] Failed to upload leftover blob:', blobUrl, e);
|
||||
return { blobUrl, relPath: null };
|
||||
}
|
||||
});
|
||||
const results = await Promise.all(uploads);
|
||||
let result = markdown;
|
||||
for (const { blobUrl, relPath } of results) {
|
||||
if (relPath) {
|
||||
result = result.replace(new RegExp(`!\\[([^\\]]*)\\]\\(${blobUrl.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\)`, 'g'), ``);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces blob/webview URIs in markdown with relative paths.
|
||||
* Call only after all saveNewMediaAndWait() promises have settled.
|
||||
* @param {string} markdown
|
||||
* @returns {string}
|
||||
*/
|
||||
function cleanImagePaths(markdown) {
|
||||
return markdown.replace(/(\!\[[^\]]*\]\()([^)]+)(\))/g, (_m, open, target, close) => {
|
||||
const { src, suffix } = splitImageTarget(target);
|
||||
const rel = blobToRelative.get(src) ?? webviewToRelative.get(src);
|
||||
return rel ? `${open}${rel}${suffix}${close}` : `${open}${src}${suffix}${close}`;
|
||||
});
|
||||
}
|
||||
|
||||
// -- Messaging ------------------------------------------------------------
|
||||
/** @param {string} type @param {Record<string, unknown>} [payload] */
|
||||
function send(type, payload = {}) { vscode.postMessage({ type, ...payload }); }
|
||||
|
||||
window.addEventListener('message', async e => {
|
||||
const msg = e.data;
|
||||
console.log('[Blog Editor] Received message:', msg.type);
|
||||
switch (msg.type) {
|
||||
case 'openSlug': requestPost(msg.slug); break;
|
||||
case 'post':
|
||||
console.log('[Blog Editor] Loading post:', msg.slug, 'Image map:', msg.imageMap ? Object.keys(msg.imageMap).length : 0);
|
||||
webviewToRelative.clear();
|
||||
blobToRelative.clear();
|
||||
if (msg.imageMap) {
|
||||
for (const [uri, rel] of Object.entries(msg.imageMap)) webviewToRelative.set(uri, rel);
|
||||
}
|
||||
await onPostLoaded(msg.slug, msg.content);
|
||||
break;
|
||||
case 'saved': markClean(); break;
|
||||
case 'renamed': {
|
||||
currentSlug = msg.newSlug;
|
||||
fmSlug.value = msg.newSlug;
|
||||
if (_pendingRenameResolve) { _pendingRenameResolve(msg.newSlug); _pendingRenameResolve = null; }
|
||||
break;
|
||||
}
|
||||
case 'mediaSaved': {
|
||||
console.log('[Blog Editor] mediaSaved:', msg.blobUrl, '->', msg.relativePath);
|
||||
blobToRelative.set(msg.blobUrl, msg.relativePath);
|
||||
webviewToRelative.set(msg.webviewUri, msg.relativePath);
|
||||
const resolveUpload = _uploadResolvers.get(msg.blobUrl);
|
||||
if (resolveUpload) {
|
||||
_uploadResolvers.delete(msg.blobUrl);
|
||||
resolveUpload(msg.relativePath);
|
||||
}
|
||||
_blobSavePromises.delete(msg.blobUrl);
|
||||
const resolve = _blobSaveResolvers.get(msg.blobUrl);
|
||||
if (resolve) { _blobSaveResolvers.delete(msg.blobUrl); resolve(); }
|
||||
markDirty();
|
||||
break;
|
||||
}
|
||||
case 'created': send('refreshPosts'); await requestPost(msg.slug); break;
|
||||
case 'error': console.error('[Blog Editor] Error:', msg.message); alert(msg.message); break;
|
||||
}
|
||||
});
|
||||
|
||||
// -- Frontmatter ----------------------------------------------------------
|
||||
/**
|
||||
* @param {string} raw
|
||||
* @returns {{ meta: Record<string, string>, body: string }}
|
||||
*/
|
||||
function parseFrontmatter(raw) {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: raw };
|
||||
const meta = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const i = line.indexOf(':');
|
||||
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
return { meta, body: m[2] };
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
function buildDocument() {
|
||||
const fields = [
|
||||
['title', fmTitle.value.trim()],
|
||||
['date', fmDate.value.trim()],
|
||||
['author', fmAuthor.value.trim()],
|
||||
['tags', fmTags.value.trim()],
|
||||
].filter(([, v]) => v);
|
||||
const body = crepe ? crepe.getMarkdown() : '';
|
||||
if (fields.length === 0) return body;
|
||||
return `---\n${fields.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${body}`;
|
||||
}
|
||||
|
||||
// -- Dirty state ----------------------------------------------------------
|
||||
function markDirty() {
|
||||
if (!currentSlug) return;
|
||||
dirty = true;
|
||||
status.textContent = `${currentSlug}.md · unsaved`;
|
||||
status.className = 'dirty';
|
||||
btnSave.disabled = false;
|
||||
}
|
||||
|
||||
function markClean() {
|
||||
dirty = false;
|
||||
status.textContent = `${currentSlug}.md · saved`;
|
||||
status.className = 'saved';
|
||||
}
|
||||
|
||||
// -- Editor lifecycle -----------------------------------------------------
|
||||
/**
|
||||
* @param {string} markdown
|
||||
*/
|
||||
async function mountEditor(markdown) {
|
||||
if (crepe) { crepe.destroy(); crepe = null; }
|
||||
editorWrap.innerHTML = '';
|
||||
crepe = new Crepe({
|
||||
root: editorWrap,
|
||||
defaultValue: markdown,
|
||||
features: {
|
||||
[imageBlockFeature]: {
|
||||
onUpload: uploadImageFile,
|
||||
},
|
||||
},
|
||||
});
|
||||
crepe.on(api => { api.markdownUpdated(() => markDirty()); });
|
||||
await crepe.create();
|
||||
}
|
||||
|
||||
// -- Post loading ---------------------------------------------------------
|
||||
/**
|
||||
* @param {string} slug
|
||||
* @param {string} content
|
||||
*/
|
||||
async function onPostLoaded(slug, content) {
|
||||
const { meta, body } = parseFrontmatter(content);
|
||||
fmTitle.value = meta.title ?? '';
|
||||
fmDate.value = meta.date ?? '';
|
||||
fmAuthor.value = meta.author ?? '';
|
||||
fmTags.value = meta.tags ?? '';
|
||||
currentSlug = slug;
|
||||
fmSlug.value = slug;
|
||||
dirty = false;
|
||||
status.textContent = `${slug}.md`;
|
||||
status.className = '';
|
||||
btnSave.disabled = false;
|
||||
await mountEditor(body);
|
||||
}
|
||||
|
||||
/** @param {string} slug */
|
||||
function requestPost(slug) {
|
||||
if (dirty && !confirm('Discard unsaved changes?')) return;
|
||||
send('getPost', { slug });
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads any unknown blob images, waits for them to land on disk,
|
||||
* then cleans paths and sends savePost.
|
||||
*/
|
||||
async function savePost() {
|
||||
if (!currentSlug) return;
|
||||
btnSave.disabled = true;
|
||||
|
||||
// Rename file if slug was changed.
|
||||
const desiredSlug = fmSlug.value.trim().toLowerCase();
|
||||
if (desiredSlug && desiredSlug !== currentSlug) {
|
||||
if (!isValidSlug(desiredSlug)) {
|
||||
alert('Invalid slug: use only lowercase letters, numbers, and hyphens.');
|
||||
fmSlug.value = currentSlug;
|
||||
btnSave.disabled = false;
|
||||
return;
|
||||
}
|
||||
const renamed = await new Promise(resolve => {
|
||||
_pendingRenameResolve = resolve;
|
||||
send('renamePost', { oldSlug: currentSlug, newSlug: desiredSlug });
|
||||
setTimeout(() => {
|
||||
if (_pendingRenameResolve) { _pendingRenameResolve = null; resolve(null); }
|
||||
}, 5000);
|
||||
});
|
||||
if (!renamed) {
|
||||
btnSave.disabled = false;
|
||||
return; // error already shown by extension
|
||||
}
|
||||
}
|
||||
|
||||
const rawDoc = buildDocument();
|
||||
console.log('[Blog Editor] savePost called, cleaning image paths...');
|
||||
const blobs = [...rawDoc.matchAll(/!\[[^\]]*\]\((blob:[^)]+)\)/g)].map(m => m[1]);
|
||||
const unknown = [...new Set(blobs.filter(b => !blobToRelative.has(b)))];
|
||||
|
||||
if (unknown.length > 0) {
|
||||
status.textContent = `Uploading ${unknown.length} image(s)...`;
|
||||
status.className = 'dirty';
|
||||
await Promise.all(unknown.map(b => saveNewMediaAndWait(b)));
|
||||
}
|
||||
|
||||
const cleanedDoc = cleanImagePaths(rawDoc);
|
||||
const finalDoc = await uploadRemainingBlobs(cleanedDoc);
|
||||
console.log('[Blog Editor] Final markdown blob count:', [...finalDoc.matchAll(/blob:/g)].length);
|
||||
send('savePost', { slug: currentSlug, content: finalDoc });
|
||||
}
|
||||
|
||||
// -- Events ---------------------------------------------------------------
|
||||
[fmTitle, fmDate, fmAuthor, fmTags, fmSlug].forEach(el => el.addEventListener('input', markDirty));
|
||||
btnSave.addEventListener('click', savePost);
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); savePost(); }
|
||||
});
|
||||
|
||||
// -- Init -----------------------------------------------------------------
|
||||
send('refreshPosts');
|
||||
send('ready');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
528
tools/blogeditor/extension.js
Normal file
528
tools/blogeditor/extension.js
Normal file
@@ -0,0 +1,528 @@
|
||||
'use strict';
|
||||
|
||||
const vscode = require('vscode');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const EXT_DIR = __dirname;
|
||||
const DIST_DIR = path.join(EXT_DIR, 'dist');
|
||||
const ROOT_DIR = path.resolve(EXT_DIR, '..');
|
||||
|
||||
/** @type {vscode.WebviewPanel | undefined} */
|
||||
let panel;
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let activeBlogDir;
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let pendingOpenSlug;
|
||||
|
||||
/** @type {BlogSidebarProvider | undefined} */
|
||||
let sidebarProvider;
|
||||
|
||||
/**
|
||||
* @implements {vscode.TreeDataProvider<vscode.TreeItem>}
|
||||
*/
|
||||
class BlogSidebarProvider {
|
||||
constructor() {
|
||||
this._onDidChangeTreeData = new vscode.EventEmitter();
|
||||
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {vscode.ProviderResult<vscode.TreeItem[]>}
|
||||
*/
|
||||
getChildren() {
|
||||
if (!activeBlogDir || !fs.existsSync(activeBlogDir)) {
|
||||
const openItem = new vscode.TreeItem('Open Blog Editor', vscode.TreeItemCollapsibleState.None);
|
||||
openItem.tooltip = 'Open the Blog Editor panel';
|
||||
openItem.command = {
|
||||
command: 'blogEditor.open',
|
||||
title: 'Open Blog Editor'
|
||||
};
|
||||
openItem.iconPath = new vscode.ThemeIcon('edit');
|
||||
return [openItem];
|
||||
}
|
||||
|
||||
const posts = fs.readdirSync(activeBlogDir)
|
||||
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
||||
.map(f => {
|
||||
const slug = path.basename(f, '.md');
|
||||
const meta = parseFrontmatterFromFile(path.join(activeBlogDir, f));
|
||||
return { slug, title: meta.title || slug, date: meta.date || '' };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.date && b.date) return b.date.localeCompare(a.date);
|
||||
if (a.date) return -1;
|
||||
if (b.date) return 1;
|
||||
return a.slug.localeCompare(b.slug);
|
||||
});
|
||||
|
||||
if (posts.length === 0) {
|
||||
const empty = new vscode.TreeItem('No posts found', vscode.TreeItemCollapsibleState.None);
|
||||
empty.iconPath = new vscode.ThemeIcon('circle-slash');
|
||||
return [empty];
|
||||
}
|
||||
|
||||
return posts.map(({ slug, title, date }) => new BlogPostItem(slug, title, date));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {vscode.TreeItem} element
|
||||
* @returns {vscode.TreeItem}
|
||||
*/
|
||||
getTreeItem(element) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses YAML-ish frontmatter from a markdown file.
|
||||
* @param {string} filePath
|
||||
* @returns {{ title?: string, date?: string }}
|
||||
*/
|
||||
function parseFrontmatterFromFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!m) return {};
|
||||
/** @type {Record<string, string>} */
|
||||
const meta = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const i = line.indexOf(':');
|
||||
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
return meta;
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
/**
|
||||
* A tree item representing a single blog post.
|
||||
* Stores the slug separately so context-menu commands can retrieve it
|
||||
* even when the label displays the human-readable title.
|
||||
*/
|
||||
class BlogPostItem extends vscode.TreeItem {
|
||||
/**
|
||||
* @param {string} slug
|
||||
* @param {string} title
|
||||
* @param {string} date
|
||||
*/
|
||||
constructor(slug, title, date) {
|
||||
super(title || slug, vscode.TreeItemCollapsibleState.None);
|
||||
this.id = slug;
|
||||
this.slug = slug;
|
||||
this.description = date || '';
|
||||
this.tooltip = `${slug}.md`;
|
||||
this.contextValue = 'blogPost';
|
||||
this.command = { command: 'blogEditor.openPost', title: 'Open Post', arguments: [slug] };
|
||||
this.iconPath = new vscode.ThemeIcon('file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks up from startDir to find node_modules containing a package.
|
||||
* @param {string} pkg
|
||||
* @param {string} startDir
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function resolveFromNearestNodeModules(pkg, startDir) {
|
||||
let dir = startDir;
|
||||
while (true) {
|
||||
const candidate = path.join(dir, 'node_modules', pkg);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {vscode.ExtensionContext} context
|
||||
*/
|
||||
async function activate(context) {
|
||||
sidebarProvider = new BlogSidebarProvider();
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
|
||||
vscode.commands.registerCommand('blogEditor.open', () => openEditor(context)),
|
||||
vscode.commands.registerCommand('blogEditor.openPost', (slug) => openEditor(context, slug)),
|
||||
vscode.commands.registerCommand('blogEditor.refreshPosts', () => sidebarProvider?.refresh()),
|
||||
vscode.commands.registerCommand('blogEditor.newPost', () => newPost(context)),
|
||||
vscode.commands.registerCommand('blogEditor.renamePost', (item) => renamePost(item))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user for a slug and creates a new blank blog post.
|
||||
* @param {vscode.ExtensionContext} context
|
||||
*/
|
||||
async function newPost(context) {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
|
||||
|
||||
const blogDir = path.join(workspaceRoot, 'website', 'data', 'blog');
|
||||
|
||||
const slug = await vscode.window.showInputBox({
|
||||
prompt: 'Enter a slug for the new post (lowercase letters, numbers, hyphens)',
|
||||
placeHolder: 'my-new-post',
|
||||
validateInput: v => isValidSlug(v) ? null : 'Use only lowercase letters, numbers, and hyphens (e.g. my-post)'
|
||||
});
|
||||
if (!slug) return;
|
||||
|
||||
const filePath = path.join(blogDir, `${slug}.md`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
vscode.window.showErrorMessage(`A post named "${slug}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
const starter = `---\ntitle: "${slug}"\ndate: "${now}"\ntags: []\n---\n\n`;
|
||||
fs.mkdirSync(blogDir, { recursive: true });
|
||||
fs.writeFileSync(filePath, starter, 'utf-8');
|
||||
activeBlogDir = blogDir;
|
||||
sidebarProvider?.refresh();
|
||||
openEditor(context, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a blog post slug via right-click context menu on a sidebar tree item.
|
||||
* @param {vscode.TreeItem} item
|
||||
*/
|
||||
async function renamePost(item) {
|
||||
const oldSlug = item?.slug ?? item?.id ?? (typeof item?.label === 'string' ? item.label : undefined);
|
||||
if (!oldSlug || typeof oldSlug !== 'string') { vscode.window.showErrorMessage('Could not determine post slug.'); return; }
|
||||
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
|
||||
|
||||
const blogDir = path.join(workspaceRoot, 'website', 'data', 'blog');
|
||||
const oldPath = path.join(blogDir, `${oldSlug}.md`);
|
||||
|
||||
const newSlug = await vscode.window.showInputBox({
|
||||
prompt: `Rename "${oldSlug}" to:`,
|
||||
value: oldSlug,
|
||||
validateInput: v => isValidSlug(v) ? null : 'Use only lowercase letters, numbers, and hyphens'
|
||||
});
|
||||
if (!newSlug || newSlug === oldSlug) return;
|
||||
|
||||
const newPath = path.join(blogDir, `${newSlug}.md`);
|
||||
if (fs.existsSync(newPath)) { vscode.window.showErrorMessage(`"${newSlug}" already exists.`); return; }
|
||||
|
||||
fs.renameSync(oldPath, newPath);
|
||||
sidebarProvider?.refresh();
|
||||
|
||||
// If this post is currently open in the editor, refresh it with the new slug.
|
||||
if (panel) {
|
||||
panel.webview.postMessage({ type: 'openSlug', slug: newSlug });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Milkdown JS + CSS bundles into dist/ if they don't exist yet.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function ensureBundle() {
|
||||
if (!fs.existsSync(DIST_DIR)) fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||
|
||||
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
|
||||
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
|
||||
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.css');
|
||||
|
||||
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
|
||||
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath)) return;
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
|
||||
async () => {
|
||||
const esbuildPkg = resolveFromNearestNodeModules('esbuild', EXT_DIR)
|
||||
?? resolveFromNearestNodeModules('esbuild', process.cwd());
|
||||
if (!esbuildPkg) {
|
||||
throw new Error('Missing prebuilt dist assets and esbuild is unavailable. Rebuild the VSIX from the repo root so dist files are packaged.');
|
||||
}
|
||||
const esbuild = require(path.join(esbuildPkg, 'lib', 'main.js'));
|
||||
const crepePkg = resolveFromNearestNodeModules('@milkdown/crepe', EXT_DIR)
|
||||
?? resolveFromNearestNodeModules('@milkdown/crepe', process.cwd());
|
||||
if (!crepePkg) {
|
||||
throw new Error('Cannot find @milkdown/crepe for bundling. Install workspace dependencies or rebuild the VSIX with prebuilt dist.');
|
||||
}
|
||||
const rootDir = path.dirname(path.dirname(esbuildPkg));
|
||||
const crepeDir = path.join(crepePkg, 'lib', 'theme');
|
||||
const fontLoaders = { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.eot': 'dataurl', '.svg': 'dataurl' };
|
||||
|
||||
const [jsResult, cssCommon, cssTheme] = await Promise.all([
|
||||
esbuild.build({
|
||||
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
|
||||
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
|
||||
}),
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(crepeDir, 'common', 'style.css')],
|
||||
bundle: true, write: false, loader: fontLoaders,
|
||||
}),
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
|
||||
bundle: true, write: false, loader: fontLoaders,
|
||||
}),
|
||||
]);
|
||||
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {vscode.ExtensionContext} context
|
||||
* @param {string} [initialSlug]
|
||||
*/
|
||||
async function openEditor(context, initialSlug) {
|
||||
if (panel) {
|
||||
panel.reveal();
|
||||
if (initialSlug) {
|
||||
panel.webview.postMessage({ type: 'openSlug', slug: initialSlug });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
|
||||
|
||||
await ensureBundle();
|
||||
|
||||
const websiteDir = path.join(workspaceRoot, 'website');
|
||||
const blogDir = path.join(websiteDir, 'data', 'blog');
|
||||
activeBlogDir = blogDir;
|
||||
sidebarProvider?.refresh();
|
||||
|
||||
if (initialSlug) pendingOpenSlug = initialSlug;
|
||||
|
||||
panel = vscode.window.createWebviewPanel(
|
||||
'blogEditor', 'Blog Editor',
|
||||
vscode.ViewColumn.One,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.file(DIST_DIR),
|
||||
vscode.Uri.file(websiteDir),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
panel.webview.html = buildHtml(panel.webview);
|
||||
|
||||
panel.webview.onDidReceiveMessage(msg => handleMessage(msg, blogDir, websiteDir), undefined, context.subscriptions);
|
||||
panel.onDidDispose(() => {
|
||||
panel = undefined;
|
||||
pendingOpenSlug = undefined;
|
||||
}, undefined, context.subscriptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the webview HTML, replacing asset URIs and nonce.
|
||||
* @param {vscode.Webview} webview
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildHtml(webview) {
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
|
||||
const commonCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-common.css')));
|
||||
const themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
|
||||
const csp = webview.cspSource;
|
||||
|
||||
const template = fs.readFileSync(path.join(EXT_DIR, 'editor.html'), 'utf-8');
|
||||
return template
|
||||
.replace(/\{\{NONCE\}\}/g, nonce)
|
||||
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
|
||||
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
|
||||
.replace('{{COMMON_CSS}}', commonCss.toString())
|
||||
.replace('{{THEME_CSS}}', themeCss.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits markdown image target into URL/path and optional trailing title segment.
|
||||
* Examples:
|
||||
* data/blog/media/a.png "Caption" -> { src: data/blog/media/a.png, suffix: "Caption" }
|
||||
* https://x/y.png -> { src: https://x/y.png, suffix: '' }
|
||||
* @param {string} target
|
||||
* @returns {{ src: string, suffix: string }}
|
||||
*/
|
||||
function splitImageTarget(target) {
|
||||
const trimmed = target.trim();
|
||||
const m = trimmed.match(/^(\S+)(\s+["'][\s\S]*["'])?$/);
|
||||
if (!m) return { src: trimmed, suffix: '' };
|
||||
return { src: m[1], suffix: m[2] ?? '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces relative image paths in markdown with webview-safe URIs.
|
||||
* @param {string} content
|
||||
* @param {string} websiteBaseUri Result of webview.asWebviewUri(websiteDir).toString()
|
||||
* @returns {string}
|
||||
*/
|
||||
function injectWebviewImageUris(content, websiteBaseUri) {
|
||||
return content.replace(/(\!\[[^\]]*\]\()([^)]+)(\))/g, (match, open, target, close) => {
|
||||
const { src, suffix } = splitImageTarget(target);
|
||||
if (/^https?:|^data:|^blob:/.test(src)) return match;
|
||||
return `${open}${websiteBaseUri}/${src.replace(/^\//, '')}${suffix}${close}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts webview URIs in markdown back to relative paths before saving.
|
||||
* @param {string} content
|
||||
* @param {string} websiteBaseUri
|
||||
* @returns {string}
|
||||
*/
|
||||
function revertWebviewImageUris(content, websiteBaseUri) {
|
||||
const escaped = websiteBaseUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return content.replace(
|
||||
new RegExp(`(\\!\\[[^\\]]*\\]\\()${escaped}/([^)]+)(\\))`, 'g'),
|
||||
(_m, open, target, close) => {
|
||||
const { src, suffix } = splitImageTarget(target);
|
||||
return `${open}${src}${suffix}${close}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles messages from the webview.
|
||||
* @param {{ type: string, slug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string }} msg
|
||||
* @param {string} blogDir
|
||||
* @param {string} websiteDir
|
||||
*/
|
||||
function handleMessage(msg, blogDir, websiteDir) {
|
||||
const webview = panel?.webview;
|
||||
if (!webview) return;
|
||||
try {
|
||||
const websiteBaseUri = webview.asWebviewUri(vscode.Uri.file(websiteDir)).toString();
|
||||
switch (msg.type) {
|
||||
case 'getPosts': {
|
||||
const slugs = fs.readdirSync(blogDir)
|
||||
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
||||
.map(f => path.basename(f, '.md'))
|
||||
.sort();
|
||||
webview.postMessage({ type: 'posts', slugs });
|
||||
break;
|
||||
}
|
||||
case 'getPost': {
|
||||
const filePath = safePostPath(blogDir, msg.slug);
|
||||
if (!filePath) return;
|
||||
const raw = fs.readFileSync(filePath, 'utf-8').replace(/!\[[^\]]*\]\(blob:[^)]+\)\r?\n?/g, '');
|
||||
// Build imageMap: webviewUri → relativePath, so the browser can reverse blob URLs
|
||||
const imageMap = {};
|
||||
const imgPattern = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||
let imgMatch;
|
||||
while ((imgMatch = imgPattern.exec(raw)) !== null) {
|
||||
const { src } = splitImageTarget(imgMatch[1]);
|
||||
if (/^https?:|^data:|^blob:/.test(src)) continue;
|
||||
const absPath = path.join(websiteDir, src.replace(/^\//, ''));
|
||||
if (fs.existsSync(absPath)) {
|
||||
const uri = webview.asWebviewUri(vscode.Uri.file(absPath)).toString();
|
||||
imageMap[uri] = src;
|
||||
}
|
||||
}
|
||||
const content = injectWebviewImageUris(raw, websiteBaseUri);
|
||||
webview.postMessage({ type: 'post', slug: msg.slug, content, imageMap });
|
||||
break;
|
||||
}
|
||||
case 'savePost': {
|
||||
const filePath = safePostPath(blogDir, msg.slug);
|
||||
if (!filePath) return;
|
||||
const content = revertWebviewImageUris(msg.content, websiteBaseUri);
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
webview.postMessage({ type: 'saved', slug: msg.slug });
|
||||
break;
|
||||
}
|
||||
case 'saveMedia': {
|
||||
const mediaDir = path.join(websiteDir, 'data', 'blog', 'media');
|
||||
fs.mkdirSync(mediaDir, { recursive: true });
|
||||
const safeName = (msg.filename || 'paste.png').replace(/[^a-z0-9._-]/gi, '_');
|
||||
const filePath = path.join(mediaDir, safeName);
|
||||
fs.writeFileSync(filePath, Buffer.from(msg.dataBase64, 'base64'));
|
||||
const relativePath = `data/blog/media/${safeName}`;
|
||||
const savedUri = webview.asWebviewUri(vscode.Uri.file(filePath)).toString();
|
||||
webview.postMessage({ type: 'mediaSaved', blobUrl: msg.blobUrl, relativePath, webviewUri: savedUri });
|
||||
break;
|
||||
}
|
||||
case 'newPost': {
|
||||
if (!isValidSlug(msg.slug)) {
|
||||
webview.postMessage({ type: 'error', message: 'Invalid slug.' });
|
||||
return;
|
||||
}
|
||||
const filePath = path.join(blogDir, `${msg.slug}.md`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
webview.postMessage({ type: 'error', message: `"${msg.slug}" already exists.` });
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(filePath, msg.content, 'utf-8');
|
||||
webview.postMessage({ type: 'created', slug: msg.slug });
|
||||
sidebarProvider?.refresh();
|
||||
break;
|
||||
}
|
||||
case 'renamePost': {
|
||||
if (!isValidSlug(msg.newSlug)) {
|
||||
webview.postMessage({ type: 'error', message: 'Invalid slug.' });
|
||||
return;
|
||||
}
|
||||
const oldFilePath = safePostPath(blogDir, msg.oldSlug);
|
||||
if (!oldFilePath) return;
|
||||
const newFilePath = path.join(blogDir, `${msg.newSlug}.md`);
|
||||
if (!newFilePath.startsWith(blogDir + path.sep)) return;
|
||||
if (fs.existsSync(newFilePath)) {
|
||||
webview.postMessage({ type: 'error', message: `"${msg.newSlug}" already exists.` });
|
||||
return;
|
||||
}
|
||||
fs.renameSync(oldFilePath, newFilePath);
|
||||
sidebarProvider?.refresh();
|
||||
webview.postMessage({ type: 'renamed', oldSlug: msg.oldSlug, newSlug: msg.newSlug });
|
||||
break;
|
||||
}
|
||||
case 'refreshPosts': {
|
||||
sidebarProvider?.refresh();
|
||||
break;
|
||||
}
|
||||
case 'ready': {
|
||||
if (pendingOpenSlug) {
|
||||
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
|
||||
pendingOpenSlug = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
webview.postMessage({ type: 'error', message: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute path to a post file, guarding against path traversal.
|
||||
* @param {string} blogDir
|
||||
* @param {string | undefined} slug
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function safePostPath(blogDir, slug) {
|
||||
if (!isValidSlug(slug)) {
|
||||
panel?.webview.postMessage({ type: 'error', message: 'Invalid slug.' });
|
||||
return null;
|
||||
}
|
||||
const resolved = path.resolve(blogDir, `${slug}.md`);
|
||||
if (!resolved.startsWith(blogDir + path.sep)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} slug
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidSlug(slug) {
|
||||
return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(slug) && slug.length <= 100;
|
||||
}
|
||||
|
||||
function deactivate() {}
|
||||
|
||||
module.exports = { activate, deactivate };
|
||||
13
tools/blogeditor/launch.json
Normal file
13
tools/blogeditor/launch.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Blog Editor Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/.vscode"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
8
tools/blogeditor/media/blog-editor.svg
Normal file
8
tools/blogeditor/media/blog-editor.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<rect x="8" y="6" width="48" height="52" rx="8" fill="#1f2937" stroke="#94a3b8" stroke-width="3"/>
|
||||
<rect x="16" y="16" width="32" height="4" rx="2" fill="#38bdf8"/>
|
||||
<rect x="16" y="26" width="24" height="4" rx="2" fill="#cbd5e1"/>
|
||||
<rect x="16" y="36" width="20" height="4" rx="2" fill="#cbd5e1"/>
|
||||
<path d="M42 42L52 52" stroke="#f59e0b" stroke-width="5" stroke-linecap="round"/>
|
||||
<circle cx="38" cy="38" r="8" stroke="#f59e0b" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 560 B |
15
tools/blogeditor/package-lock.json
generated
Normal file
15
tools/blogeditor/package-lock.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "blog-editor",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "blog-editor",
|
||||
"version": "0.1.3",
|
||||
"engines": {
|
||||
"vscode": "^1.80.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
tools/blogeditor/package.json
Normal file
84
tools/blogeditor/package.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "blog-editor",
|
||||
"displayName": "Blog Editor",
|
||||
"description": "WYSIWYG Milkdown blog post editor for this workspace",
|
||||
"version": "0.1.12",
|
||||
"publisher": "local",
|
||||
"engines": { "vscode": "^1.80.0" },
|
||||
"categories": ["Other"],
|
||||
"repository": { "type": "git", "url": "https://github.com/local/blog-editor" },
|
||||
"activationEvents": [],
|
||||
"main": "./extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "blogEditor.open",
|
||||
"title": "Open Blog Editor",
|
||||
"category": "Blog"
|
||||
},
|
||||
{
|
||||
"command": "blogEditor.openPost",
|
||||
"title": "Open Blog Post",
|
||||
"category": "Blog"
|
||||
},
|
||||
{
|
||||
"command": "blogEditor.refreshPosts",
|
||||
"title": "Refresh Blog Posts",
|
||||
"category": "Blog",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "blogEditor.newPost",
|
||||
"title": "New Post",
|
||||
"category": "Blog",
|
||||
"icon": "$(add)"
|
||||
},
|
||||
{
|
||||
"command": "blogEditor.renamePost",
|
||||
"title": "Rename Post",
|
||||
"category": "Blog"
|
||||
}
|
||||
],
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "blog-editor-container",
|
||||
"title": "Blog Editor",
|
||||
"icon": "media/blog-editor.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"blog-editor-container": [
|
||||
{
|
||||
"id": "blog-editor-sidebar",
|
||||
"name": "Posts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{
|
||||
"command": "blogEditor.newPost",
|
||||
"when": "view == blog-editor-sidebar",
|
||||
"group": "navigation@1"
|
||||
},
|
||||
{
|
||||
"command": "blogEditor.refreshPosts",
|
||||
"when": "view == blog-editor-sidebar",
|
||||
"group": "navigation@2"
|
||||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "blogEditor.renamePost",
|
||||
"when": "view == blog-editor-sidebar && viewItem == blogPost",
|
||||
"group": "inline"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"package": "node ../build-dist.js && npx @vscode/vsce package --out blog-editor.vsix --no-dependencies"
|
||||
}
|
||||
}
|
||||
33
tools/navLinks.js
Normal file
33
tools/navLinks.js
Normal file
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NavLink
|
||||
* @property {string} label
|
||||
* @property {string} href
|
||||
* @property {boolean} [external]
|
||||
*/
|
||||
|
||||
/** @type {NavLink[]} */
|
||||
const NAV_LINKS = [
|
||||
{ label: 'home', href: '/' },
|
||||
{ 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 `<a>` 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}<a href="${href}"${attrs} class="quick-link">${label}</a>`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
module.exports = { NAV_LINKS, renderNavLinkItems };
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
title: Markdown Formatting Test
|
||||
date: 2026-04-20
|
||||
author: Test Author
|
||||
tags: test, formatting, markdown
|
||||
---
|
||||
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
### Heading 3
|
||||
|
||||
#### Heading 4
|
||||
|
||||
##### Heading 5
|
||||
|
||||
###### Heading 6
|
||||
|
||||
## Text Formatting
|
||||
|
||||
This is **bold text** and this is *italic text* and this is ***bold italic text***.
|
||||
|
||||
Here's some `inline code` in a sentence.
|
||||
|
||||
## Links
|
||||
|
||||
Here's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).
|
||||
|
||||
## Lists
|
||||
|
||||
Unordered list:
|
||||
- First item
|
||||
- Second item
|
||||
- Third item
|
||||
|
||||
Ordered list:
|
||||
1. First step
|
||||
2. Second step
|
||||
3. Third step
|
||||
|
||||
## Code Blocks
|
||||
|
||||
Standard JavaScript:
|
||||
```javascript
|
||||
const hello = "world";
|
||||
console.log(hello);
|
||||
|
||||
function test() {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
Python with syntax highlighting:
|
||||
```python
|
||||
def hello_world():
|
||||
print("Hello, World!")
|
||||
return True
|
||||
```
|
||||
|
||||
Code with max height (200px):
|
||||
```javascript {maxHeight: 200}
|
||||
// This is a long code block that will scroll
|
||||
const data = [1, 2, 3, 4, 5];
|
||||
|
||||
function processData(arr) {
|
||||
return arr.map(x => x * 2);
|
||||
}
|
||||
|
||||
console.log(processData(data));
|
||||
|
||||
// Adding more lines to demonstrate scrolling
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(`Iteration ${i}`);
|
||||
}
|
||||
|
||||
// Even more content
|
||||
const obj = {
|
||||
name: "Test",
|
||||
value: 42,
|
||||
nested: {
|
||||
deep: true
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> This is a blockquote.
|
||||
> It can span multiple lines.
|
||||
> And continues here.
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
---
|
||||
|
||||
## Images
|
||||
|
||||

|
||||
|
||||
## Mixed Content
|
||||
|
||||
You can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.
|
||||
|
||||
### Nested Lists
|
||||
|
||||
- Top level item
|
||||
- Another top level
|
||||
- Nested item (if supported)
|
||||
- Another nested
|
||||
|
||||
1. Numbered item
|
||||
2. Another numbered
|
||||
- Mixed with bullets
|
||||
- More bullets
|
||||
|
||||
## Special Characters
|
||||
|
||||
Testing special chars: < > & " '
|
||||
|
||||
## Long Paragraph
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
BIN
website/data/blog/media/paste-1778426005842-2tr04.png
Normal file
BIN
website/data/blog/media/paste-1778426005842-2tr04.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
BIN
website/data/blog/media/paste-1778426403986-pbrbo.jpeg
Normal file
BIN
website/data/blog/media/paste-1778426403986-pbrbo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 629 KiB |
@@ -1,52 +0,0 @@
|
||||
---
|
||||
title: Building Interactive Node Graphs
|
||||
date: 2026-04-15
|
||||
author: Max Litruv Boonzaayer
|
||||
tags: javascript, graph-systems, tutorial
|
||||
---
|
||||
|
||||
# Building Interactive Node Graphs
|
||||
|
||||
In this post, I'll share some insights on building interactive node-based graph systems for the web.
|
||||
|
||||
## Why Node Graphs?
|
||||
|
||||
Node graphs are powerful visual programming tools that make complex logic easier to understand and manipulate:
|
||||
|
||||
- **Visual Clarity**: See the flow of data and execution at a glance
|
||||
- **Modularity**: Each node is a self-contained unit
|
||||
- **Flexibility**: Easy to add new node types and behaviors
|
||||
|
||||
## The Architecture
|
||||
|
||||
The system is built on several key classes:
|
||||
|
||||
### NodeBase
|
||||
The abstract base class that all nodes inherit from. It defines the blueprint interface:
|
||||
|
||||
```javascript
|
||||
class NodeBase {
|
||||
static NodeType = "";
|
||||
static BlueprintPure_GetDefaultPins() { }
|
||||
BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { }
|
||||
async BlueprintCallable_Execute(ctx) { }
|
||||
}
|
||||
```
|
||||
|
||||
### Node Registry
|
||||
Automatically registers node types and creates instances based on type strings.
|
||||
|
||||
### Execution Context
|
||||
Handles the graph traversal and execution flow when nodes are triggered.
|
||||
|
||||
## Creating Custom Nodes
|
||||
|
||||
To create a new node type:
|
||||
|
||||
1. Extend `NodeBase`
|
||||
2. Set a unique `NodeType`
|
||||
3. Define pins with `BlueprintPure_GetDefaultPins()`
|
||||
4. Implement rendering in `BlueprintNativeEvent_OnRender()`
|
||||
5. Implement logic in `BlueprintCallable_Execute()`
|
||||
|
||||
That's the blueprint system in a nutshell!
|
||||
@@ -1,46 +1,27 @@
|
||||
---
|
||||
title: Welcome to My Blog
|
||||
date: 2026-04-19
|
||||
title: Welcome
|
||||
date: 2026-05-10
|
||||
author: Max Litruv Boonzaayer
|
||||
tags: welcome, meta, introduction
|
||||
tags: announcement
|
||||
---
|
||||
Congratulations on making it here, you've stumbled onto a game dev's blog, who's spent the past 12 years buried in Unreal Engine.
|
||||
|
||||
# Welcome to My Blog
|
||||
From studying Programming and 3D Art & Animation at the Academy of Interactive Entertainment, to developing architectural experiences at [Orbit Solutions](https://www.orbitsolutions.com.au/), running 1-on-1 mentoring sessions on [Fiverr](https://www.fiverr.com/litruv), working with [Superfarm](https://superverse.co/), and now building my own games and tools with [Mates.dev](https://mates.dev) - this blog is where I will be dumping my processes.
|
||||
|
||||
This is my first blog post! I'm excited to share my thoughts and experiences with you.
|
||||
Expect game dev, systems design, experiments, tools, prototypes, and the occasional questionable decision that somehow worked out in the end.
|
||||
|
||||
## What This Blog Is About
|
||||
<br />
|
||||
|
||||
This blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?
|
||||

|
||||
|
||||
### Features
|
||||
Like most people getting into game development, I started with terrible projects, helping others make technical decisions and squishing bugs along the way, I had many overly ambitious ideas, and an unhealthy confidence that I could probably work it out.
|
||||
|
||||
- 📝 Markdown support with YAML front matter
|
||||
- 📅 Date-based organization
|
||||
- 🏷️ Tag system for categorization
|
||||
- 🎨 Interactive node-based visualization
|
||||
- 📁 Media support in `data/blog/media/`
|
||||
*Somehow that part never really changed.*
|
||||
|
||||
## Technical Details
|
||||
A lot of my work seems to revolve around systems-heavy gameplay, tools, modding workflows, UI frameworks, interaction systems, materials, save systems and going through weird technical problems that Unreal Engine has to solve. Somewhere along the way I also fell into hardware projects, self-hosted infrastructure, audio gear, embedded devices, and other rabbit holes that tend to consume my entire spare time.
|
||||
|
||||
The build system automatically:
|
||||
1. Scans the `data/blog/` directory for `.md` files
|
||||
2. Parses YAML front matter for metadata
|
||||
3. Generates a `blogs.json` file
|
||||
4. Makes posts available to the BlogPostNode
|
||||
These days I'm focused on building my own games and tech, while trying to document more of the process instead of letting years of experiments vanish into forgotten folders and messages.
|
||||
|
||||
You can reference images like this:
|
||||

|
||||

|
||||
|
||||
## Code Examples
|
||||
|
||||
Here's some example code:
|
||||
|
||||
```javascript
|
||||
const greeting = "Hello, World!";
|
||||
console.log(greeting);
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Stay tuned for more posts! This is just the beginning of an exciting journey.
|
||||
This blog is mostly going to be a mix of development logs, technical breakdowns, experiments, ideas, failures, discoveries and whatever else crawls out of the workshop. Some posts will just document strange decisions made at unreasonable hours, the others might even be useful. Both are worth keeping around.
|
||||
|
||||
@@ -2,26 +2,34 @@
|
||||
{
|
||||
"slug": "markdown-test",
|
||||
"title": "Markdown Formatting Test",
|
||||
"date": "2026-04-20",
|
||||
"date": "2026-09-11",
|
||||
"author": "Test Author",
|
||||
"tags": [
|
||||
"test",
|
||||
"formatting",
|
||||
"markdown"
|
||||
],
|
||||
"content": "\r\n# Heading 1\r\n\r\n## Heading 2\r\n\r\n### Heading 3\r\n\r\n#### Heading 4\r\n\r\n##### Heading 5\r\n\r\n###### Heading 6\r\n\r\n## Text Formatting\r\n\r\nThis is **bold text** and this is *italic text* and this is ***bold italic text***.\r\n\r\nHere's some `inline code` in a sentence.\r\n\r\n## Links\r\n\r\nHere's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).\r\n\r\n## Lists\r\n\r\nUnordered list:\r\n- First item\r\n- Second item\r\n- Third item\r\n\r\nOrdered list:\r\n1. First step\r\n2. Second step\r\n3. Third step\r\n\r\n## Code Blocks\r\n\r\nStandard JavaScript:\r\n```javascript\r\nconst hello = \"world\";\r\nconsole.log(hello);\r\n\r\nfunction test() {\r\n return true;\r\n}\r\n```\r\n\r\nPython with syntax highlighting:\r\n```python\r\ndef hello_world():\r\n print(\"Hello, World!\")\r\n return True\r\n```\r\n\r\nCode with max height (200px):\r\n```javascript {maxHeight: 200}\r\n// This is a long code block that will scroll\r\nconst data = [1, 2, 3, 4, 5];\r\n\r\nfunction processData(arr) {\r\n return arr.map(x => x * 2);\r\n}\r\n\r\nconsole.log(processData(data));\r\n\r\n// Adding more lines to demonstrate scrolling\r\nfor (let i = 0; i < 10; i++) {\r\n console.log(`Iteration ${i}`);\r\n}\r\n\r\n// Even more content\r\nconst obj = {\r\n name: \"Test\",\r\n value: 42,\r\n nested: {\r\n deep: true\r\n }\r\n};\r\n```\r\n\r\n## Blockquotes\r\n\r\n> This is a blockquote.\r\n> It can span multiple lines.\r\n> And continues here.\r\n\r\n## Horizontal Rule\r\n\r\n---\r\n\r\n## Images\r\n\r\n\r\n\r\n## Mixed Content\r\n\r\nYou can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.\r\n\r\n### Nested Lists\r\n\r\n- Top level item\r\n- Another top level\r\n - Nested item (if supported)\r\n - Another nested\r\n\r\n1. Numbered item\r\n2. Another numbered\r\n - Mixed with bullets\r\n - More bullets\r\n\r\n## Special Characters\r\n\r\nTesting special chars: < > & \" ' \r\n\r\n## Long Paragraph\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\r\n\r\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\r\n"
|
||||
"content": "# Heading 1 test 123\n\n## Heading 2\n\n### Heading 3\n\n#### Heading 4\n\n##### Heading 5\n\n###### Heading 6\n\n## Text Formatting\n\nThis is **bold text** and this is *italic text* and this is ***bold italic text***.\n\nHere's some `inline code` in a sentence.\n\n## Links\n\nHere's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).\n\n## Lists\n\nUnordered list:\n\n* First item\n\n* Second item\n\n* Third item\n\nOrdered list:\n\n1. First step\n2. Second step\n3. Third step\n\n## Code Blocks\n\nStandard JavaScript:\n\n```javascript\nconst hello = \"world\";\nconsole.log(hello);\n\nfunction test() {\n return true;\n}\n```\n\nPython with syntax highlighting:\n\n```python\ndef hello_world():\n print(\"Hello, World!\")\n return True\n```\n\nCode with max height (200px):\n\n```javascript\n// This is a long code block that will scroll\nconst data = [1, 2, 3, 4, 5];\n\nfunction processData(arr) {\n return arr.map(x => x * 2);\n}\n\nconsole.log(processData(data));\n\n// Adding more lines to demonstrate scrolling\nfor (let i = 0; i < 10; i++) {\n console.log(`Iteration ${i}`);\n}\n\n// Even more content\nconst obj = {\n name: \"Test\",\n value: 42,\n nested: {\n deep: true\n }\n};\n```\n\n## Blockquotes\n\n> This is a blockquote.\n> It can span multiple lines.\n> And continues here.\n\n## Horizontal Rule\n\n***\n\n## Images\n\n\n\n\n\n## Mixed Content\n\n<br />\n\nYou can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with](https://example.com) **[bold](https://example.com)** [text](https://example.com) too.\n\n### Nested Lists\n\n* Top level itemsss\n\n* Another top level\n\n * Nested item (if supported)\n\n * Another nested\n\n1. Numbered item\n2. Another numbered\n\n * Mixed with bullets\n\n * More bullets\n\n## Special Characters\n\nTesting special chars: < > & \" '\n\n## Long Paragraph\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
|
||||
},
|
||||
{
|
||||
"slug": "testddd",
|
||||
"title": "test d d d d",
|
||||
"date": "2026-05-10",
|
||||
"author": "it me",
|
||||
"tags": [],
|
||||
"content": "asdffdsaasdffdsa\n\n| <br /> | <br /> | <br /> | <br /> |\n| :----- | :----- | :----- | -----: |\n| <br /> | <br /> | <br /> | <br /> |\n| <br /> | <br /> | <br /> | <br /> |\n\n<br />\n\nthis is a test post, should't publish i guess, but who's reading lmao\n"
|
||||
},
|
||||
{
|
||||
"slug": "welcome",
|
||||
"title": "Welcome to My Blog",
|
||||
"date": "2026-04-19",
|
||||
"date": "2026-05-10",
|
||||
"author": "Max Litruv Boonzaayer",
|
||||
"tags": [
|
||||
"welcome",
|
||||
"meta",
|
||||
"introduction"
|
||||
],
|
||||
"content": "\r\n# Welcome to My Blog\r\n\r\nThis is my first blog post! I'm excited to share my thoughts and experiences with you.\r\n\r\n## What This Blog Is About\r\n\r\nThis blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?\r\n\r\n### Features\r\n\r\n- 📝 Markdown support with YAML front matter\r\n- 📅 Date-based organization\r\n- 🏷️ Tag system for categorization\r\n- 🎨 Interactive node-based visualization\r\n- 📁 Media support in `data/blog/media/`\r\n\r\n## Technical Details\r\n\r\nThe build system automatically:\r\n1. Scans the `data/blog/` directory for `.md` files\r\n2. Parses YAML front matter for metadata\r\n3. Generates a `blogs.json` file\r\n4. Makes posts available to the BlogPostNode\r\n\r\nYou can reference images like this:\r\n\r\n\r\n## Code Examples\r\n\r\nHere's some example code:\r\n\r\n```javascript\r\nconst greeting = \"Hello, World!\";\r\nconsole.log(greeting);\r\n```\r\n\r\n## Conclusion\r\n\r\nStay tuned for more posts! This is just the beginning of an exciting journey.\r\n"
|
||||
"content": "# Welcome to My Blog\n\nThis is my first blog post! I'm excited to share my thoughts and experiences with you.\n\n## What This Blog Is About\n\nThis blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?\n\n### Features\n\n* 📝 Markdown support with YAML front matter\n\n* 📅 Date-based organization\n\n* 🏷️ Tag system for categorization\n\n* 🎨 Interactive node-based visualization\n\n* 📁 Media support in `data/blog/media/`\n\n## Technical Details\n\nThe build system automatically:\n\n1. Scans the `data/blog/` directory for `.md` files\n2. Parses YAML front matter for metadata\n3. Generates a `blogs.json` file\n4. Makes posts available to the BlogPostNode\n\nYou can reference images like this:\n\n\n## Code Examples\n\nHere's some example code:\n\n```javascript\nconst greeting = \"Hello, World!\";\r\nconsole.log(greeting);\n```\n\n## Conclusion\n\nStay tuned for more posts! This is just the beginning of an exciting journey.\n"
|
||||
},
|
||||
{
|
||||
"slug": "node-graphs",
|
||||
|
||||
@@ -644,13 +644,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blog_testddd",
|
||||
"type": "blog_post",
|
||||
"title": "test d d d d",
|
||||
"blogSlug": "testddd",
|
||||
"position": {
|
||||
"x": 2200,
|
||||
"y": 900
|
||||
},
|
||||
"width": 600,
|
||||
"inputs": [
|
||||
{
|
||||
"id": "exec_in",
|
||||
"name": "Previous",
|
||||
"direction": "input",
|
||||
"kind": "exec"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "exec_out",
|
||||
"name": "Next",
|
||||
"direction": "output",
|
||||
"kind": "exec"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blog_welcome",
|
||||
"type": "blog_post",
|
||||
"title": "Welcome to My Blog",
|
||||
"blogSlug": "welcome",
|
||||
"position": {
|
||||
"x": 2200,
|
||||
"x": 2900,
|
||||
"y": 900
|
||||
},
|
||||
"width": 600,
|
||||
@@ -677,7 +704,7 @@
|
||||
"title": "Building Interactive Node Graphs",
|
||||
"blogSlug": "node-graphs",
|
||||
"position": {
|
||||
"x": 2900,
|
||||
"x": 3600,
|
||||
"y": 900
|
||||
},
|
||||
"width": 600,
|
||||
@@ -928,13 +955,25 @@
|
||||
"pinId": "exec_out"
|
||||
},
|
||||
"to": {
|
||||
"nodeId": "blog_welcome",
|
||||
"nodeId": "blog_testddd",
|
||||
"pinId": "exec_in"
|
||||
},
|
||||
"kind": "exec"
|
||||
},
|
||||
{
|
||||
"id": "blog_connection_1",
|
||||
"from": {
|
||||
"nodeId": "blog_testddd",
|
||||
"pinId": "exec_out"
|
||||
},
|
||||
"to": {
|
||||
"nodeId": "blog_welcome",
|
||||
"pinId": "exec_in"
|
||||
},
|
||||
"kind": "exec"
|
||||
},
|
||||
{
|
||||
"id": "blog_connection_2",
|
||||
"from": {
|
||||
"nodeId": "blog_welcome",
|
||||
"pinId": "exec_out"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Lit.ruv.wtf - Interactive Node Graph</title>
|
||||
<title>Lit.ruv.wtf - Technical art meets sleep deprivation</title>
|
||||
<meta name="description" content="Personal site of Lit.ruv.wtf. Graph editor edition">
|
||||
|
||||
<!-- Favicons -->
|
||||
@@ -18,36 +18,39 @@
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://lit.ruv.wtf/">
|
||||
<meta property="og:title" content="Lit.ruv.wtf - Interactive Node Graph">
|
||||
<meta property="og:title" content="Lit.ruv.wtf - Technical art meets sleep deprivation">
|
||||
<meta property="og:description" content="Personal site of Lit.ruv.wtf. Graph editor edition">
|
||||
<meta property="og:image" content="https://lit.ruv.wtf/banner.webp">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://lit.ruv.wtf/">
|
||||
<meta name="twitter:title" content="Lit.ruv.wtf - Interactive Node Graph">
|
||||
<meta name="twitter:title" content="Lit.ruv.wtf - Technical art meets sleep deprivation">
|
||||
<meta name="twitter:description" content="Personal site of Lit.ruv.wtf. Graph editor edition">
|
||||
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp">
|
||||
|
||||
<!-- Preconnect to external domains -->
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
||||
<link rel="dns-prefetch" href="https://unpkg.com">
|
||||
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||
<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||
<script defer src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="workspaceCanvas" class="workspace-canvas">
|
||||
<nav class="quick-links">
|
||||
<a href="/docs/" class="quick-link">docs</a>
|
||||
<a href="https://github.com/litruv" target="_blank" rel="noopener" class="quick-link">github</a>
|
||||
<a href="https://bsky.app/profile/lit.mates.dev" target="_blank" rel="noopener" class="quick-link">bluesky</a>
|
||||
<a href="/materials" class="quick-link">materials</a>
|
||||
<!-- NAV_LINKS -->
|
||||
</nav>
|
||||
|
||||
<svg id="connectionLayer" class="connection-layer"></svg>
|
||||
|
||||
@@ -202,6 +202,13 @@ export class ItemDragger {
|
||||
if (!this.#enabled) return;
|
||||
const target = /** @type {HTMLElement} */ (e.target);
|
||||
if (target.closest(".pin-handle")) return;
|
||||
|
||||
// Allow text selection in blog/info node bodies
|
||||
const nodeBody = target.closest(".node-body");
|
||||
if (nodeBody && (node.type === 'info' || node.type === 'blog_post')) {
|
||||
return; // Don't drag, allow text selection
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Lightweight markdown-to-HTML renderer.
|
||||
* Supports: headings (h1–h3), bold, italic, inline code, code blocks,
|
||||
* blockquotes, unordered lists, ordered lists, horizontal rules, links, paragraphs.
|
||||
* blockquotes, unordered lists, ordered lists, horizontal rules, links, tables, paragraphs.
|
||||
* No external dependencies.
|
||||
*/
|
||||
|
||||
@@ -45,8 +45,8 @@ export class MarkdownRenderer {
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Split into blocks separated by blank lines
|
||||
const blocks = html.split(/\n{2,}/);
|
||||
// Split into blocks, preserving loose-list continuity across blank lines
|
||||
const blocks = MarkdownRenderer.#splitBlocks(html);
|
||||
const parts = blocks.map(block => {
|
||||
block = block.trim();
|
||||
// Restore code blocks
|
||||
@@ -91,19 +91,37 @@ export class MarkdownRenderer {
|
||||
|
||||
const langClass = lang ? ` language-${lang}` : "";
|
||||
const styleAttr = maxHeight ? ` style="max-height: ${maxHeight}px; overflow-y: auto;"` : "";
|
||||
return `<pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre>`;
|
||||
const escapedCode = MarkdownRenderer.#escape(code);
|
||||
return `<div class="md-code-block"><button class="md-copy-btn" data-code="${escapedCode}" title="Copy code">📋</button><pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre></div>`;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^[-*_]{3,}$/.test(block)) {
|
||||
return `<hr class="md-hr" />`;
|
||||
return `<div class="md-hr" role="separator"><span class="md-hr-line"></span><span class="md-hr-arrow"></span></div>`;
|
||||
}
|
||||
|
||||
// Headings
|
||||
const headingMatch = block.match(/^(#{1,6})\s+(.+)$/m);
|
||||
if (headingMatch && block.split("\n").length === 1) {
|
||||
const level = headingMatch[1].length;
|
||||
return `<h${level} class="md-h${level}">${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;
|
||||
const pin = level === 1 ? '<span class="md-h1-pin" aria-hidden="true"></span>'
|
||||
: level === 2 ? '<span class="md-h2-pin" aria-hidden="true"></span>'
|
||||
: '';
|
||||
return `<h${level} class="md-h${level}">${pin}${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;
|
||||
}
|
||||
|
||||
// Tables
|
||||
if (MarkdownRenderer.#isTableBlock(block)) {
|
||||
return MarkdownRenderer.#renderTable(block);
|
||||
}
|
||||
|
||||
// Standalone image
|
||||
const imgBlockMatch = block.match(/^!\[([^\]]*)\]\((.+)\)$/);
|
||||
if (imgBlockMatch) {
|
||||
const { src, caption, imageAttributes } = MarkdownRenderer.#parseImageMeta(imgBlockMatch[1], imgBlockMatch[2]);
|
||||
if (src) {
|
||||
return `<figure class="md-figure"><img class="md-img" src="${src}" alt="${caption}"${imageAttributes} />${caption ? `<figcaption class="md-figcaption">${caption}</figcaption>` : ""}</figure>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
@@ -114,20 +132,17 @@ export class MarkdownRenderer {
|
||||
|
||||
// Unordered list
|
||||
if (/^[-*+] /.test(block)) {
|
||||
const items = block.split("\n").filter(Boolean).map(line => {
|
||||
const content = line.replace(/^[-*+]\s+/, "");
|
||||
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
|
||||
});
|
||||
return `<ul class="md-ul">${items.join("")}</ul>`;
|
||||
return MarkdownRenderer.#renderList(block, "ul");
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (/^\d+\. /.test(block)) {
|
||||
const items = block.split("\n").filter(Boolean).map(line => {
|
||||
const content = line.replace(/^\d+\.\s+/, "");
|
||||
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
|
||||
});
|
||||
return `<ol class="md-ol">${items.join("")}</ol>`;
|
||||
return MarkdownRenderer.#renderList(block, "ol");
|
||||
}
|
||||
|
||||
// Standalone line break tags
|
||||
if (MarkdownRenderer.#isLineBreakBlock(block)) {
|
||||
return MarkdownRenderer.#renderLineBreakBlock(block);
|
||||
}
|
||||
|
||||
// Paragraph (handle single-line line breaks within block)
|
||||
@@ -135,6 +150,273 @@ export class MarkdownRenderer {
|
||||
return `<p class="md-p">${lines.join("<br />")}</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders nested lists (ul or ol) with indentation support.
|
||||
* @param {string} block
|
||||
* @param {"ul" | "ol"} listType
|
||||
* @returns {string}
|
||||
*/
|
||||
static #renderList(block, listType) {
|
||||
const lines = block.split("\n").filter(Boolean);
|
||||
const items = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const indent = line.match(/^(\s*)/)[1].length;
|
||||
|
||||
// Extract content
|
||||
let content;
|
||||
if (listType === "ul") {
|
||||
content = line.replace(/^\s*[-*+]\s+/, "");
|
||||
} else {
|
||||
content = line.replace(/^\s*\d+\.\s+/, "");
|
||||
}
|
||||
|
||||
// Look ahead for nested items (more indented)
|
||||
let nestedBlock = "";
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const nextLine = lines[j];
|
||||
const nextIndent = nextLine.match(/^(\s*)/)[1].length;
|
||||
if (nextIndent > indent) {
|
||||
nestedBlock += (nestedBlock ? "\n" : "") + nextLine.slice(indent + 2); // Remove parent indent
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Render item with nested list if present
|
||||
let itemHtml = MarkdownRenderer.#renderInline(content);
|
||||
if (nestedBlock) {
|
||||
const nestedType = /^[-*+] /.test(nestedBlock.trim()) ? "ul" : "ol";
|
||||
itemHtml += MarkdownRenderer.#renderList(nestedBlock, nestedType);
|
||||
}
|
||||
items.push(`<li class="md-li">${itemHtml}</li>`);
|
||||
i = j;
|
||||
}
|
||||
|
||||
const tag = listType === "ul" ? "ul" : "ol";
|
||||
return `<${tag} class="md-${tag}">${items.join("")}</${tag}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a block is a markdown pipe table.
|
||||
*
|
||||
* @param {string} block
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static #isTableBlock(block) {
|
||||
const lines = block.split("\n").map(line => line.trim()).filter(Boolean);
|
||||
if (lines.length < 2) return false;
|
||||
if (!lines[0].includes("|") || !lines[1].includes("|")) return false;
|
||||
const separators = MarkdownRenderer.#splitTableRow(lines[1]);
|
||||
if (separators.length === 0) return false;
|
||||
return separators.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a markdown pipe table block.
|
||||
*
|
||||
* @param {string} block
|
||||
* @returns {string}
|
||||
*/
|
||||
static #renderTable(block) {
|
||||
const lines = block.split("\n").map(line => line.trim()).filter(Boolean);
|
||||
const headerCells = MarkdownRenderer.#splitTableRow(lines[0]);
|
||||
const separatorCells = MarkdownRenderer.#splitTableRow(lines[1]);
|
||||
const columnCount = Math.max(headerCells.length, separatorCells.length);
|
||||
const alignments = MarkdownRenderer.#parseTableAlignments(separatorCells, columnCount);
|
||||
|
||||
const normalizedHeader = MarkdownRenderer.#normalizeTableCells(headerCells, columnCount);
|
||||
const thead = `<thead><tr>${normalizedHeader
|
||||
.map((cell, index) => `<th class="md-th"${MarkdownRenderer.#getTableAlignStyle(alignments[index])}>${MarkdownRenderer.#renderInline(cell)}</th>`)
|
||||
.join("")}</tr></thead>`;
|
||||
|
||||
const bodyRows = lines.slice(2)
|
||||
.map(row => MarkdownRenderer.#normalizeTableCells(MarkdownRenderer.#splitTableRow(row), columnCount))
|
||||
.map(rowCells => `<tr>${rowCells
|
||||
.map((cell, index) => `<td class="md-td"${MarkdownRenderer.#getTableAlignStyle(alignments[index])}>${MarkdownRenderer.#renderInline(cell)}</td>`)
|
||||
.join("")}</tr>`)
|
||||
.join("");
|
||||
const tbody = `<tbody>${bodyRows}</tbody>`;
|
||||
|
||||
return `<div class="md-table-wrap"><table class="md-table">${thead}${tbody}</table></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a markdown table row into cells.
|
||||
*
|
||||
* @param {string} row
|
||||
* @returns {string[]}
|
||||
*/
|
||||
static #splitTableRow(row) {
|
||||
let normalized = row.trim();
|
||||
if (normalized.startsWith("|")) normalized = normalized.slice(1);
|
||||
if (normalized.endsWith("|")) normalized = normalized.slice(0, -1);
|
||||
return normalized.split("|").map(cell => cell.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes row cells to a fixed column count.
|
||||
*
|
||||
* @param {string[]} cells
|
||||
* @param {number} columnCount
|
||||
* @returns {string[]}
|
||||
*/
|
||||
static #normalizeTableCells(cells, columnCount) {
|
||||
const normalized = cells.slice(0, columnCount);
|
||||
while (normalized.length < columnCount) normalized.push("");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown alignment hints from the table separator row.
|
||||
*
|
||||
* @param {string[]} separatorCells
|
||||
* @param {number} columnCount
|
||||
* @returns {Array<"left" | "center" | "right">}
|
||||
*/
|
||||
static #parseTableAlignments(separatorCells, columnCount) {
|
||||
const normalized = MarkdownRenderer.#normalizeTableCells(separatorCells, columnCount);
|
||||
return normalized.map(cell => {
|
||||
const trimmed = cell.trim();
|
||||
const startsWithColon = trimmed.startsWith(":");
|
||||
const endsWithColon = trimmed.endsWith(":");
|
||||
if (startsWithColon && endsWithColon) return "center";
|
||||
if (endsWithColon) return "right";
|
||||
return "left";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a text-align style attribute for table cells.
|
||||
*
|
||||
* @param {"left" | "center" | "right"} align
|
||||
* @returns {string}
|
||||
*/
|
||||
static #getTableAlignStyle(align) {
|
||||
return ` style="text-align: ${align};"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects blocks that only contain one or more HTML line break tags.
|
||||
*
|
||||
* @param {string} block
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static #isLineBreakBlock(block) {
|
||||
const lines = block.split("\n").map(line => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return false;
|
||||
return lines.every(line => /^<br\s*\/?>$/i.test(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a standalone line-break block without paragraph wrappers.
|
||||
*
|
||||
* @param {string} block
|
||||
* @returns {string}
|
||||
*/
|
||||
static #renderLineBreakBlock(block) {
|
||||
const lineCount = block.split("\n").map(line => line.trim()).filter(Boolean).length;
|
||||
return Array.from({ length: lineCount }, () => "<br />").join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits markdown into renderable blocks while keeping loose/nested list items together.
|
||||
*
|
||||
* @param {string} markdown
|
||||
* @returns {string[]}
|
||||
*/
|
||||
static #splitBlocks(markdown) {
|
||||
const lines = markdown.split("\n");
|
||||
const blocks = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
while (index < lines.length && lines[index].trim() === "") {
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index >= lines.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const line = lines[index];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (/^___CODEBLOCK_\d+___$/.test(trimmedLine)) {
|
||||
blocks.push(trimmedLine);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MarkdownRenderer.#isListMarkerLine(line)) {
|
||||
const listLines = [line];
|
||||
index++;
|
||||
|
||||
while (index < lines.length) {
|
||||
const current = lines[index];
|
||||
|
||||
if (current.trim() === "") {
|
||||
let lookAhead = index;
|
||||
while (lookAhead < lines.length && lines[lookAhead].trim() === "") {
|
||||
lookAhead++;
|
||||
}
|
||||
|
||||
if (lookAhead >= lines.length) {
|
||||
index = lookAhead;
|
||||
break;
|
||||
}
|
||||
|
||||
const next = lines[lookAhead];
|
||||
if (MarkdownRenderer.#isListMarkerLine(next) || /^\s+/.test(next)) {
|
||||
listLines.push(...lines.slice(index, lookAhead));
|
||||
index = lookAhead;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (MarkdownRenderer.#isListMarkerLine(current) || /^\s+/.test(current)) {
|
||||
listLines.push(current);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
blocks.push(listLines.join("\n").trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines = [line];
|
||||
index++;
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== "") {
|
||||
paragraphLines.push(lines[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
blocks.push(paragraphLines.join("\n"));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a line starts a markdown list item.
|
||||
*
|
||||
* @param {string} line
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static #isListMarkerLine(line) {
|
||||
return /^\s*(?:[-*+]\s+|\d+\.\s+)/.test(line);
|
||||
}
|
||||
|
||||
// ─── Inline-level ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -145,6 +427,9 @@ export class MarkdownRenderer {
|
||||
// Escape HTML first, then re-apply formatting
|
||||
let out = MarkdownRenderer.#escape(text);
|
||||
|
||||
// Allow explicit line break tags in markdown text only.
|
||||
out = out.replace(/<br\s*\/?>/gi, "<br />");
|
||||
|
||||
// Inline code (must come before bold/italic to avoid breaking backtick content)
|
||||
out = out.replace(/`([^`]+)`/g, (_, code) =>
|
||||
`<code class="md-code">${code}</code>`
|
||||
@@ -159,6 +444,16 @@ export class MarkdownRenderer {
|
||||
// Italic
|
||||
out = out.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
||||
|
||||
// Images  — must be matched before links
|
||||
out = out.replace(
|
||||
/!\[([^\]]*)\]\((.+?)\)/g,
|
||||
(_, altText, rawMeta) => {
|
||||
const { src, caption, imageAttributes } = MarkdownRenderer.#parseImageMeta(altText, rawMeta);
|
||||
if (!src) return MarkdownRenderer.#escape(altText);
|
||||
return `<img class="md-img md-img--inline" src="${src}" alt="${caption}"${imageAttributes} />`;
|
||||
}
|
||||
);
|
||||
|
||||
// Links [text](url)
|
||||
out = out.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
@@ -203,7 +498,88 @@ export class MarkdownRenderer {
|
||||
*/
|
||||
static #sanitizeUrl(url) {
|
||||
const trimmed = url.trim();
|
||||
// Allow absolute URLs, root-relative, hash links, relative-dot paths, and plain relative paths
|
||||
if (/^(https?:\/\/|mailto:|\/|#|\.)/.test(trimmed)) return trimmed;
|
||||
// Allow relative paths (e.g. data/blog/media/image.png) — no protocol = safe relative
|
||||
if (/^[a-zA-Z0-9_\-][a-zA-Z0-9_.\-\/]*$/.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies intrinsic pixel scaling to markdown images with a size token.
|
||||
*
|
||||
* @param {ParentNode} container
|
||||
* @returns {void}
|
||||
*/
|
||||
static applyImageScale(container) {
|
||||
container.querySelectorAll("img[data-md-scale]").forEach(img => {
|
||||
const scale = parseFloat(img.getAttribute("data-md-scale") || "");
|
||||
if (!Number.isFinite(scale) || scale <= 0) return;
|
||||
|
||||
const applyScale = () => {
|
||||
if (!img.naturalWidth) return;
|
||||
const targetWidthPx = Math.max(1, Math.round(img.naturalWidth * scale));
|
||||
img.style.width = `${targetWidthPx}px`;
|
||||
img.style.height = "auto";
|
||||
img.style.maxWidth = "100%";
|
||||
};
|
||||
|
||||
if (img.complete) {
|
||||
applyScale();
|
||||
return;
|
||||
}
|
||||
|
||||
img.addEventListener("load", applyScale, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown image metadata including optional title and size token.
|
||||
*
|
||||
* Supported format: 
|
||||
* - Numeric alt-only values (e.g. 0.50) are treated as intrinsic size scale metadata.
|
||||
* - Title text is used as the visible caption when present.
|
||||
*
|
||||
* @param {string} rawAlt
|
||||
* @param {string} rawMeta
|
||||
* @returns {{ src: string | null, caption: string, imageAttributes: string }}
|
||||
*/
|
||||
static #parseImageMeta(rawAlt, rawMeta) {
|
||||
const meta = rawMeta.trim();
|
||||
const titleMatch = meta.match(/^(\S+)(?:\s+"([\s\S]*?)")?$/);
|
||||
if (!titleMatch) {
|
||||
return {
|
||||
src: null,
|
||||
caption: MarkdownRenderer.#escape(rawAlt),
|
||||
imageAttributes: "",
|
||||
};
|
||||
}
|
||||
|
||||
const src = MarkdownRenderer.#sanitizeUrl(titleMatch[1]);
|
||||
const titleCaption = titleMatch[2] ? MarkdownRenderer.#escape(titleMatch[2]) : "";
|
||||
const trimmedAlt = rawAlt.trim();
|
||||
const sizeScale = MarkdownRenderer.#parseImageSizeScale(trimmedAlt);
|
||||
const caption = titleCaption || (sizeScale === null ? MarkdownRenderer.#escape(trimmedAlt) : "");
|
||||
const imageAttributes = sizeScale !== null ? ` data-md-scale="${sizeScale}"` : "";
|
||||
|
||||
return {
|
||||
src,
|
||||
caption,
|
||||
imageAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a numeric image scale token from markdown alt text.
|
||||
*
|
||||
* @param {string} rawAlt
|
||||
* @returns {number | null}
|
||||
*/
|
||||
static #parseImageSizeScale(rawAlt) {
|
||||
if (!/^\d*\.?\d+$/.test(rawAlt)) return null;
|
||||
const value = parseFloat(rawAlt);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
if (value <= 0 || value > 1) return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +433,7 @@ export class NodeRenderer {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const text = await response.text();
|
||||
target.innerHTML = MarkdownRenderer.render(text);
|
||||
MarkdownRenderer.applyImageScale(target);
|
||||
} catch (err) {
|
||||
target.innerHTML = `<p class="md-error">Failed to load content.</p>`;
|
||||
console.error(`[NodeRenderer] Could not load markdown "${src}":`, err);
|
||||
|
||||
@@ -481,6 +481,19 @@ export class WorkspaceNavigator {
|
||||
/** @param {PointerEvent} e */
|
||||
#onPointerDown(e) {
|
||||
if (e.button !== 0 && e.button !== 2) return;
|
||||
|
||||
// Allow text selection in blog/info nodes
|
||||
const nodeBody = e.target.closest('.node-body');
|
||||
if (nodeBody) {
|
||||
const blueprintNode = nodeBody.closest('.blueprint-node');
|
||||
if (blueprintNode) {
|
||||
const nodeType = blueprintNode.dataset.nodeType;
|
||||
if (nodeType === 'info' || nodeType === 'blog_post') {
|
||||
return; // Don't pan, allow text selection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#beginPan(e);
|
||||
}
|
||||
|
||||
|
||||
213
website/scripts/blogPage.js
Normal file
213
website/scripts/blogPage.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer.js";
|
||||
|
||||
/**
|
||||
* Decodes markdown payload from a JSON script element.
|
||||
*
|
||||
* @param {HTMLScriptElement | null} payloadEl
|
||||
* @returns {string}
|
||||
*/
|
||||
function readMarkdownPayload(payloadEl) {
|
||||
if (!payloadEl) return "";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadEl.textContent || "\"\"");
|
||||
return typeof parsed === "string" ? parsed : "";
|
||||
} catch (error) {
|
||||
console.error("Failed to parse blog markdown payload", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires copy buttons rendered by MarkdownRenderer.
|
||||
*
|
||||
* @param {ParentNode} container
|
||||
* @returns {void}
|
||||
*/
|
||||
function wireCopyButtons(container) {
|
||||
container.querySelectorAll(".md-copy-btn").forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const code = button.getAttribute("data-code");
|
||||
if (!code) return;
|
||||
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const originalText = button.textContent;
|
||||
button.textContent = "✓";
|
||||
button.classList.add("copied");
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.classList.remove("copied");
|
||||
}, 2000);
|
||||
}).catch((error) => {
|
||||
console.error("Failed to copy code", error);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders blog markdown on static post pages using the shared renderer.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderStaticBlogPost() {
|
||||
/** @type {HTMLElement | null} */
|
||||
const target = document.querySelector("[data-blog-post-content]");
|
||||
if (!target) return;
|
||||
|
||||
/** @type {HTMLScriptElement | null} */
|
||||
const payload = document.querySelector("#blogPostMarkdown");
|
||||
const markdown = readMarkdownPayload(payload);
|
||||
|
||||
target.innerHTML = MarkdownRenderer.render(markdown);
|
||||
MarkdownRenderer.applyImageScale(target);
|
||||
wireCopyButtons(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the compact top bar when the hero logo scrolls out of view.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function setupScrollTopBar() {
|
||||
const body = document.body;
|
||||
const bar = document.querySelector("[data-blog-scroll-topbar]");
|
||||
const heroLogo = document.querySelector(".blog-logo-link");
|
||||
|
||||
if (!bar) return;
|
||||
|
||||
if (heroLogo && "IntersectionObserver" in window) {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
body.classList.toggle("blog-scrolled", !entry.isIntersecting);
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
observer.observe(heroLogo);
|
||||
} else {
|
||||
// Fallback: fixed pixel threshold
|
||||
const applyState = () => {
|
||||
body.classList.toggle("blog-scrolled", window.scrollY >= 80);
|
||||
};
|
||||
window.addEventListener("scroll", applyState, { passive: true });
|
||||
applyState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function initializeBlogPage() {
|
||||
renderStaticBlogPost();
|
||||
setupScrollTopBar();
|
||||
setupPeekSplines();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initializeBlogPage, { once: true });
|
||||
} else {
|
||||
initializeBlogPage();
|
||||
}
|
||||
@@ -55,9 +55,12 @@ export class BlogPostNode extends NodeBase {
|
||||
metaDiv.style.cssText = "margin-bottom: 1em; padding-bottom: 0.5em; border-bottom: 1px solid rgba(255,255,255,0.1);";
|
||||
|
||||
if (post.title) {
|
||||
const titleEl = document.createElement("h2");
|
||||
const titleEl = document.createElement("a");
|
||||
titleEl.href = `/blog/${encodeURIComponent(post.slug)}/`;
|
||||
titleEl.textContent = post.title;
|
||||
titleEl.style.cssText = "margin: 0 0 0.5em 0; font-size: 1.5em;";
|
||||
titleEl.style.cssText = "display:block; margin: 0 0 0.5em 0; font-size: 1.5em; color: inherit; text-decoration: none;";
|
||||
titleEl.addEventListener('mouseenter', () => titleEl.style.textDecoration = 'underline');
|
||||
titleEl.addEventListener('mouseleave', () => titleEl.style.textDecoration = 'none');
|
||||
metaDiv.appendChild(titleEl);
|
||||
}
|
||||
|
||||
@@ -71,9 +74,12 @@ export class BlogPostNode extends NodeBase {
|
||||
}
|
||||
|
||||
if (metaInfo.length > 0) {
|
||||
const infoEl = document.createElement("div");
|
||||
infoEl.style.cssText = "color: rgba(255,255,255,0.6); font-size: 0.9em;";
|
||||
const infoEl = document.createElement("a");
|
||||
infoEl.href = `/blog/${encodeURIComponent(post.slug)}/`;
|
||||
infoEl.style.cssText = "display:block; color: rgba(255,255,255,0.6); font-size: 0.9em; text-decoration: none;";
|
||||
infoEl.textContent = metaInfo.join(' • ');
|
||||
infoEl.addEventListener('mouseenter', () => infoEl.style.textDecoration = 'underline');
|
||||
infoEl.addEventListener('mouseleave', () => infoEl.style.textDecoration = 'none');
|
||||
metaDiv.appendChild(infoEl);
|
||||
}
|
||||
|
||||
@@ -99,6 +105,28 @@ export class BlogPostNode extends NodeBase {
|
||||
// Import and use MarkdownRenderer directly
|
||||
const { MarkdownRenderer } = await import('../MarkdownRenderer.js');
|
||||
contentDiv.innerHTML = MarkdownRenderer.render(post.content);
|
||||
MarkdownRenderer.applyImageScale(contentDiv);
|
||||
|
||||
// Add copy button handlers
|
||||
contentDiv.querySelectorAll('.md-copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const code = btn.getAttribute('data-code');
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = '✓';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
body.innerHTML = `<p style="color: #ff6b6b;">Error loading blog post: ${error.message}</p>`;
|
||||
|
||||
@@ -40,3 +40,5 @@ export class PrintNode extends NodeBase {
|
||||
}
|
||||
|
||||
NodeRegistry.UCLASS_Register(PrintNode);
|
||||
|
||||
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Personal Site</title>
|
||||
<link rel="stylesheet" href="styles/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="workspaceCanvas" class="workspace-canvas">
|
||||
<svg id="connectionLayer" class="connection-layer"></svg>
|
||||
<div id="worldLayer" class="world-layer">
|
||||
<div id="nodeLayer" class="node-layer"></div>
|
||||
</div>
|
||||
|
||||
<div class="hud">
|
||||
<button id="resetViewBtn" class="hud-btn" title="Reset View">⌖</button>
|
||||
</div>
|
||||
|
||||
<div id="debugPanel" class="debug-panel collapsed">
|
||||
<div class="debug-header">
|
||||
<div class="debug-title">Debug</div>
|
||||
<button class="debug-toggle-btn" title="Toggle debug panel" aria-label="Toggle debug panel">
|
||||
<svg class="debug-eye-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path class="eye-visible" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle class="eye-visible" cx="12" cy="12" r="3"></circle>
|
||||
<path class="eye-hidden" d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" style="display: none;"></path>
|
||||
<line class="eye-hidden" x1="1" y1="1" x2="23" y2="23" style="display: none;"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Zoom</span>
|
||||
<span class="debug-value debug-zoom">1.000</span>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">DPR</span>
|
||||
<span class="debug-value debug-dpr">1.0</span>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Viewport</span>
|
||||
<span class="debug-value debug-viewport">0 × 0</span>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Center</span>
|
||||
<span class="debug-value debug-center">0.0, 0.0</span>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Viewbox</span>
|
||||
<span class="debug-value debug-viewbox">—</span>
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">NodeAnchor</span>
|
||||
<span class="debug-value debug-anchor">—</span>
|
||||
</div>
|
||||
<div class="debug-divider"></div>
|
||||
<label class="debug-row debug-check-row">
|
||||
<span class="debug-label">Draggable</span>
|
||||
<input class="debug-draggable-cb" type="checkbox" />
|
||||
</label>
|
||||
<label class="debug-row debug-check-row">
|
||||
<span class="debug-label">WorldBoxes</span>
|
||||
<input class="debug-worldbox-cb" type="checkbox" />
|
||||
</label>
|
||||
<label class="debug-row debug-check-row">
|
||||
<span class="debug-label">Viewbox</span>
|
||||
<input class="debug-viewbox-cb" type="checkbox" />
|
||||
</label>
|
||||
<div class="debug-divider"></div>
|
||||
<button class="debug-copy-json-btn" title="Copy current graph as JSON">Copy JSON</button>
|
||||
<button class="debug-show-properties-btn" title="Open properties panel">Show Properties</button>
|
||||
<div class="debug-row debug-active-row" hidden>
|
||||
<span class="debug-label debug-active-label">—</span>
|
||||
<span class="debug-value debug-active-pos">0.0, 0.0</span>
|
||||
<button class="debug-copy-btn" title="Copy graph.json">Copy graph</button>
|
||||
</div>
|
||||
|
||||
<div class="debug-divider" id="debugNodeEditorDivider" hidden></div>
|
||||
<div id="debugNodeEditor" class="debug-node-editor" hidden>
|
||||
<div class="debug-subtitle">Node Editor</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">ID</span>
|
||||
<input class="debug-input debug-node-id" type="text" readonly />
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Type</span>
|
||||
<input class="debug-input debug-node-type" type="text" readonly />
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Title</span>
|
||||
<input class="debug-input debug-node-title" type="text" />
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">X</span>
|
||||
<input class="debug-input debug-node-x" type="number" step="1" />
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Y</span>
|
||||
<input class="debug-input debug-node-y" type="number" step="1" />
|
||||
</div>
|
||||
<div class="debug-row">
|
||||
<span class="debug-label">Width</span>
|
||||
<input class="debug-input debug-node-width" type="number" step="1" />
|
||||
</div>
|
||||
|
||||
<div class="debug-subtitle">Inputs</div>
|
||||
<div id="debugInputsList" class="debug-pins-list"></div>
|
||||
<button class="debug-add-btn debug-add-input-btn">+ Add Input</button>
|
||||
|
||||
<div class="debug-subtitle">Outputs</div>
|
||||
<div id="debugOutputsList" class="debug-pins-list"></div>
|
||||
<button class="debug-add-btn debug-add-output-btn">+ Add Output</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Properties Editor Panel (Right Side) -->
|
||||
<div id="propertiesPanel" class="properties-panel collapsed">
|
||||
<button class="properties-panel-tab" title="Open properties panel" aria-label="Open properties panel">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="properties-header">
|
||||
<h2 class="properties-title">Properties</h2>
|
||||
<button class="properties-toggle-btn" title="Close properties panel" aria-label="Close properties panel">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="properties-content">
|
||||
<!-- Node Properties Section -->
|
||||
<div id="nodePropertiesSection" class="properties-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h3>Node</h3>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">ID</label>
|
||||
<input type="text" class="prop-input" id="propNodeId" readonly />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Type</label>
|
||||
<input type="text" class="prop-input" id="propNodeType" readonly />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Title</label>
|
||||
<input type="text" class="prop-input" id="propNodeTitle" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Position X</label>
|
||||
<input type="number" class="prop-input" id="propNodeX" step="20" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Position Y</label>
|
||||
<input type="number" class="prop-input" id="propNodeY" step="20" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Width</label>
|
||||
<input type="number" class="prop-input" id="propNodeWidth" placeholder="Auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focus Options Section -->
|
||||
<div id="focusOptionsSection" class="properties-section" style="display: none;">
|
||||
<div class="section-header collapsible">
|
||||
<h3>Focus Options</h3>
|
||||
<button class="section-toggle">▼</button>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Duration (ms)</label>
|
||||
<input type="number" class="prop-input" id="propFocusDuration" placeholder="550" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Anchor X</label>
|
||||
<input type="number" class="prop-input" id="propFocusAnchorX" step="0.1" min="0" max="1" placeholder="0.5" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Anchor Y</label>
|
||||
<input type="number" class="prop-input" id="propFocusAnchorY" step="0.1" min="0" max="1" placeholder="0.5" />
|
||||
</div>
|
||||
|
||||
<div class="prop-divider"></div>
|
||||
<h4 class="prop-subtitle">Default WorldBox</h4>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Width</label>
|
||||
<input type="number" class="prop-input" id="propWorldBoxWidth" placeholder="Auto" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Height</label>
|
||||
<input type="number" class="prop-input" id="propWorldBoxHeight" placeholder="Auto" />
|
||||
</div>
|
||||
|
||||
<div class="prop-divider"></div>
|
||||
<h4 class="prop-subtitle">Responsive Breakpoints</h4>
|
||||
<div id="breakpointsList" class="breakpoints-list"></div>
|
||||
<button class="prop-btn-add" id="addBreakpointBtn">+ Add Breakpoint</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph Settings Section -->
|
||||
<div id="graphSettingsSection" class="properties-section">
|
||||
<div class="section-header collapsible">
|
||||
<h3>Graph Settings</h3>
|
||||
<button class="section-toggle">▼</button>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Draggable</label>
|
||||
<input type="checkbox" class="prop-checkbox" id="propGraphDraggable" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Intro Duration (ms)</label>
|
||||
<input type="number" class="prop-input" id="propIntroDuration" placeholder="700" />
|
||||
</div>
|
||||
|
||||
<div class="prop-divider"></div>
|
||||
<h4 class="prop-subtitle">Initial Viewport</h4>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">X</label>
|
||||
<input type="number" class="prop-input" id="propInitViewX" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Y</label>
|
||||
<input type="number" class="prop-input" id="propInitViewY" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Width</label>
|
||||
<input type="number" class="prop-input" id="propInitViewWidth" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Height</label>
|
||||
<input type="number" class="prop-input" id="propInitViewHeight" />
|
||||
</div>
|
||||
|
||||
<div class="prop-divider"></div>
|
||||
<button class="prop-btn-primary" id="applySettingsBtn">Apply Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Selection Message -->
|
||||
<div id="noSelectionMessage" class="no-selection">
|
||||
<p>Select a node to edit its properties</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="nodeTemplate">
|
||||
<article class="blueprint-node" draggable="false">
|
||||
<header class="node-header">
|
||||
<div class="node-title"></div>
|
||||
</header>
|
||||
<section class="node-io node-inputs"></section>
|
||||
<section class="node-io node-outputs"></section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<template id="pinTemplate">
|
||||
<div class="pin" draggable="false">
|
||||
<span class="pin-label"></span>
|
||||
<span class="pin-handle" tabindex="-1"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- WorldBox Overlay Layer -->
|
||||
<svg id="worldBoxLayer" class="worldbox-layer" style="display: none;"></svg>
|
||||
|
||||
<!-- Viewbox Overlay Layer -->
|
||||
<svg id="viewboxLayer" class="viewbox-layer" style="display: none;"></svg>
|
||||
|
||||
<!-- JSON Editor Modal -->
|
||||
<script type="module" src="scripts/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user