mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-25 11:16:02 +10:00
Compare commits
37 Commits
40fd8e8e42
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a4926c1368 | |||
| b09a329d62 | |||
| 655d16ea6b | |||
| cf08709151 | |||
| 37d6388244 | |||
| 13fa54401f | |||
| ffe6ab9f90 | |||
| 7091b6844e | |||
| 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();
|
||||
|
||||
3869
package-lock.json
generated
3869
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
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,12 @@
|
||||
"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",
|
||||
"dictionary-en-us": "^2.2.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"nspell": "^2.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
360
tools/StaticBlogGenerator.js
Normal file
360
tools/StaticBlogGenerator.js
Normal file
@@ -0,0 +1,360 @@
|
||||
'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>',
|
||||
' <section class="blog-comments" data-blog-comments data-matrix-homeserver="https://chat.ruv.wtf" data-matrix-room="#general:chat.ruv.wtf"></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">',
|
||||
' <div class="quick-links-inner">',
|
||||
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>',
|
||||
' </div>',
|
||||
'</nav>',
|
||||
this.renderScrollTopBar(),
|
||||
bodyHtml,
|
||||
this.renderFooter(),
|
||||
' <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>',
|
||||
' <div class="blog-scroll-topbar-inner">',
|
||||
' <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>',
|
||||
' </div>',
|
||||
'</header>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
renderFooter() {
|
||||
return [
|
||||
'<footer class="blog-footer">',
|
||||
' <div class="blog-footer-inner">',
|
||||
' <a href="/" class="blog-footer-logo" aria-label="Back to main site">',
|
||||
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-footer-logo-img" />',
|
||||
' </a>',
|
||||
' <div class="blog-footer-links">',
|
||||
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>',
|
||||
' </div>',
|
||||
` <p class="blog-footer-copy">© ${new Date().getFullYear()} lit.ruv.wtf</p>`,
|
||||
' </div>',
|
||||
'</footer>',
|
||||
].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.
BIN
tools/blogeditor/blog-editor.vsix
Normal file
BIN
tools/blogeditor/blog-editor.vsix
Normal file
Binary file not shown.
102
tools/blogeditor/build-dist.js
Normal file
102
tools/blogeditor/build-dist.js
Normal file
@@ -0,0 +1,102 @@
|
||||
'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',
|
||||
};
|
||||
|
||||
const TEXT_LOADERS = {
|
||||
'.aff': 'text',
|
||||
'.dic': 'text',
|
||||
};
|
||||
|
||||
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('⏳ Bundling spellcheck JS…');
|
||||
const spellcheckJs = await esbuild.build({
|
||||
entryPoints: [path.join(ROOT, 'spellcheck.js')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
minify: true,
|
||||
write: false,
|
||||
loader: TEXT_LOADERS,
|
||||
});
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckJs.outputFiles[0].contents);
|
||||
console.log(`✓ spellcheck.js ${(spellcheckJs.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); });
|
||||
44
tools/blogeditor/bump-version.js
Normal file
44
tools/blogeditor/bump-version.js
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PACKAGE_JSON_PATH = path.join(__dirname, 'package.json');
|
||||
|
||||
/**
|
||||
* Parses a semantic version string and bumps the patch number.
|
||||
* @param {string} version
|
||||
* @returns {string}
|
||||
*/
|
||||
function bumpPatchVersion(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid version format: "${version}". Expected x.y.z`);
|
||||
}
|
||||
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
const patch = Number(match[3]) + 1;
|
||||
return `${major}.${minor}.${patch}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads package.json, increments patch version, and writes the updated file.
|
||||
*/
|
||||
function main() {
|
||||
const raw = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8');
|
||||
const pkg = JSON.parse(raw);
|
||||
|
||||
if (typeof pkg.version !== 'string') {
|
||||
throw new Error('package.json is missing a valid string version field.');
|
||||
}
|
||||
|
||||
const previous = pkg.version;
|
||||
const next = bumpPatchVersion(previous);
|
||||
pkg.version = next;
|
||||
|
||||
fs.writeFileSync(PACKAGE_JSON_PATH, `${JSON.stringify(pkg, null, 4)}\n`, 'utf8');
|
||||
console.log(`[blog-editor] version bumped: ${previous} -> ${next}`);
|
||||
}
|
||||
|
||||
main();
|
||||
1678
tools/blogeditor/editor.html
Normal file
1678
tools/blogeditor/editor.html
Normal file
File diff suppressed because it is too large
Load Diff
991
tools/blogeditor/extension.js
Normal file
991
tools/blogeditor/extension.js
Normal file
@@ -0,0 +1,991 @@
|
||||
'use strict';
|
||||
|
||||
const vscode = require('vscode');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const extensionManifest = require('./package.json');
|
||||
|
||||
const EXT_DIR = __dirname;
|
||||
const DIST_DIR = path.join(EXT_DIR, 'dist');
|
||||
const ROOT_DIR = path.resolve(EXT_DIR, '..');
|
||||
const EXTENSION_VERSION = extensionManifest.version;
|
||||
|
||||
/** @type {vscode.WebviewPanel | undefined} */
|
||||
let panel;
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let activeBlogDir;
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let pendingOpenSlug;
|
||||
|
||||
/** @type {BlogSidebarProvider | undefined} */
|
||||
let sidebarProvider;
|
||||
|
||||
/** @type {vscode.OutputChannel | undefined} */
|
||||
let output;
|
||||
|
||||
const DEFAULT_LM_STUDIO_BASE_URL = 'http://127.0.0.1:1234';
|
||||
const LM_STUDIO_CHAT_ENDPOINTS = ['/v1/chat/completions'];
|
||||
const LM_STUDIO_MODELS_ENDPOINTS = ['/api/v1/models', '/v1/models'];
|
||||
const BLOG_TAGS_JSON_SCHEMA = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
title: 'Blog Tags',
|
||||
type: 'array',
|
||||
maxItems: 20,
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 32,
|
||||
pattern: '^[a-z0-9]+(-[a-z0-9]+)*$',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @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, author?: string, tags?: 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 {}; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {number}
|
||||
*/
|
||||
function parseDateToMs(value) {
|
||||
if (!value) return 0;
|
||||
const ts = Date.parse(value);
|
||||
return Number.isFinite(ts) ? ts : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets best previous author by most recent post date (fallback: file mtime).
|
||||
* @param {string} blogDir
|
||||
* @returns {string}
|
||||
*/
|
||||
function getPreviousAuthor(blogDir) {
|
||||
if (!fs.existsSync(blogDir)) return '';
|
||||
const entries = fs.readdirSync(blogDir)
|
||||
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
||||
.map(file => {
|
||||
const filePath = path.join(blogDir, file);
|
||||
const meta = parseFrontmatterFromFile(filePath);
|
||||
const mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||
return { author: (meta.author || '').trim(), dateMs: parseDateToMs(meta.date || ''), mtimeMs };
|
||||
})
|
||||
.filter(item => item.author)
|
||||
.sort((a, b) => {
|
||||
if (a.dateMs !== b.dateMs) return b.dateMs - a.dateMs;
|
||||
return b.mtimeMs - a.mtimeMs;
|
||||
});
|
||||
return entries[0]?.author || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} raw
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseTagArray(raw) {
|
||||
if (typeof raw !== 'string') {
|
||||
if (raw && typeof raw === 'object' && Array.isArray(raw.tags)) {
|
||||
return raw.tags
|
||||
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const cleaned = raw
|
||||
.replace(/^```(?:json)?/i, '')
|
||||
.replace(/```$/i, '')
|
||||
.trim();
|
||||
|
||||
if (cleaned.startsWith('{') && cleaned.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.tags)) {
|
||||
return parsed.tags
|
||||
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to array and split parsing.
|
||||
}
|
||||
}
|
||||
|
||||
const start = cleaned.indexOf('[');
|
||||
const end = cleaned.lastIndexOf(']');
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned.slice(start, end + 1));
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to comma/newline split.
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
.split(/[\n,]/)
|
||||
.map(v => v.replace(/^[\s\-•*#]+/, '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} tags
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function normalizeGeneratedTags(tags) {
|
||||
/** @type {string[]} */
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const tag of tags) {
|
||||
const clean = tag
|
||||
.replace(/^#+/, '')
|
||||
.replace(/["'`]/g, '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!clean || seen.has(clean)) continue;
|
||||
seen.add(clean);
|
||||
out.push(clean);
|
||||
if (out.length >= 12) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FALLBACK_TAG_RULES = [
|
||||
{ tag: 'unreal-engine', patterns: [/\bunreal engine\b/i, /\bue\b/i] },
|
||||
{ tag: 'game-development', patterns: [/\bgame dev\b/i, /\bgame development\b/i, /\bgamedev\b/i] },
|
||||
{ tag: 'programming', patterns: [/\bprogramming\b/i, /\bdeveloper\b/i, /\bdevelopment\b/i] },
|
||||
{ tag: '3d-art', patterns: [/\b3d art\b/i, /\b3d\b/i] },
|
||||
{ tag: 'animation', patterns: [/\banimation\b/i, /\banimations\b/i] },
|
||||
{ tag: 'systems-design', patterns: [/\bsystems design\b/i, /\binteraction systems\b/i, /\bsystems-heavy\b/i] },
|
||||
{ tag: 'tools', patterns: [/\btools\b/i, /\btooling\b/i] },
|
||||
{ tag: 'development-logs', patterns: [/\bdevelopment logs\b/i, /\bdev logs\b/i, /\blog\b/i] },
|
||||
{ tag: 'embedded-devices', patterns: [/\bembedded devices\b/i, /\bembedded\b/i] },
|
||||
{ tag: 'hardware', patterns: [/\bhardware\b/i, /\baudio gear\b/i] },
|
||||
{ tag: 'ui-frameworks', patterns: [/\bui frameworks\b/i, /\bui framework\b/i] },
|
||||
{ tag: 'modding', patterns: [/\bmodding\b/i, /\bmodding workflows\b/i] },
|
||||
{ tag: 'prototypes', patterns: [/\bprototypes\b/i, /\bprototype\b/i] },
|
||||
{ tag: 'experiments', patterns: [/\bexperiments\b/i, /\bexperiment\b/i] },
|
||||
];
|
||||
|
||||
const FALLBACK_TAG_STOP_WORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'blog', 'both', 'but', 'by', 'for',
|
||||
'from', 'here', 'into', 'its', 'just', 'make', 'more', 'onto', 'post', 'that',
|
||||
'the', 'their', 'this', 'those', 'through', 'use', 'useful', 'using', 'welcome',
|
||||
'with', 'worth', 'you', 'your',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {{ title: string, body: string, tags: string }} payload
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function buildFallbackTags(payload) {
|
||||
const sourceText = `${payload.title || ''}\n${payload.body || ''}`;
|
||||
const fallback = normalizeGeneratedTags(parseTagArray(payload.tags || ''));
|
||||
const seen = new Set(fallback);
|
||||
|
||||
for (const rule of FALLBACK_TAG_RULES) {
|
||||
if (seen.has(rule.tag)) continue;
|
||||
if (rule.patterns.some(pattern => pattern.test(sourceText))) {
|
||||
fallback.push(rule.tag);
|
||||
seen.add(rule.tag);
|
||||
}
|
||||
if (fallback.length >= 8) return fallback;
|
||||
}
|
||||
|
||||
const titleTokens = (payload.title || '')
|
||||
.toLowerCase()
|
||||
.match(/[a-z0-9]+/g) || [];
|
||||
for (const token of titleTokens) {
|
||||
if (token.length < 4 || FALLBACK_TAG_STOP_WORDS.has(token) || seen.has(token)) continue;
|
||||
fallback.push(token);
|
||||
seen.add(token);
|
||||
if (fallback.length >= 8) break;
|
||||
}
|
||||
|
||||
return normalizeGeneratedTags(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
function log(message) {
|
||||
const ts = new Date().toISOString();
|
||||
output?.appendLine(`[${ts}] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} urlOrBase
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveLmStudioBaseUrl(urlOrBase) {
|
||||
const raw = (urlOrBase || '').trim();
|
||||
if (!raw) return DEFAULT_LM_STUDIO_BASE_URL;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
const pathname = parsed.pathname || '/';
|
||||
const cut = pathname.search(/\/(api\/v1|v1)\b/i);
|
||||
parsed.pathname = cut >= 0 ? pathname.slice(0, cut) || '/' : pathname;
|
||||
return parsed.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return DEFAULT_LM_STUDIO_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function buildChatEndpointCandidates(baseUrl) {
|
||||
return LM_STUDIO_CHAT_ENDPOINTS.map(p => `${baseUrl}${p}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} data
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractLmStudioMessageContent(data) {
|
||||
if (!data || typeof data !== 'object') return '';
|
||||
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||
|
||||
const choices = Array.isArray(obj.choices) ? obj.choices : [];
|
||||
for (const choice of choices) {
|
||||
if (!choice || typeof choice !== 'object') continue;
|
||||
const c = /** @type {Record<string, unknown>} */ (choice);
|
||||
const message = c.message;
|
||||
if (message && typeof message === 'object') {
|
||||
const messageObj = /** @type {Record<string, unknown>} */ (message);
|
||||
const content = messageObj.content;
|
||||
if (typeof content === 'string' && content.trim()) return content;
|
||||
const reasoningContent = messageObj.reasoning_content;
|
||||
if (typeof reasoningContent === 'string' && reasoningContent.trim()) return reasoningContent;
|
||||
}
|
||||
if (typeof c.text === 'string' && c.text.trim()) return c.text;
|
||||
}
|
||||
|
||||
if (typeof obj.output_text === 'string' && obj.output_text.trim()) return obj.output_text;
|
||||
if (typeof obj.content === 'string' && obj.content.trim()) return obj.content;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @param {AbortSignal} signal
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function detectLmStudioModel(baseUrl, signal) {
|
||||
for (const endpointPath of LM_STUDIO_MODELS_ENDPOINTS) {
|
||||
const endpoint = `${baseUrl}${endpointPath}`;
|
||||
try {
|
||||
log(`AI tags: probing models endpoint ${endpoint}`);
|
||||
const res = await fetch(endpoint, { method: 'GET', signal });
|
||||
log(`AI tags: models endpoint status ${res.status} (${endpoint})`);
|
||||
if (!res.ok) continue;
|
||||
/** @type {{ data?: Array<{ id?: string, state?: string, loaded?: boolean }> }} */
|
||||
const data = await res.json();
|
||||
const models = Array.isArray(data?.data) ? data.data : [];
|
||||
const loaded = models.find(m => m && typeof m.id === 'string' && (m.loaded === true || String(m.state || '').toLowerCase() === 'loaded'));
|
||||
if (loaded?.id) {
|
||||
log(`AI tags: selected loaded model ${loaded.id}`);
|
||||
return loaded.id;
|
||||
}
|
||||
const first = models.find(m => m && typeof m.id === 'string');
|
||||
if (first?.id) {
|
||||
log(`AI tags: selected first model ${first.id}`);
|
||||
return first.id;
|
||||
}
|
||||
} catch {
|
||||
// Try next endpoint variant.
|
||||
}
|
||||
}
|
||||
log('AI tags: could not detect model from API, using fallback local-model');
|
||||
return 'local-model';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} endpoint
|
||||
* @param {Record<string, unknown>} body
|
||||
* @param {AbortSignal} signal
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function postLmStudio(endpoint, body, signal) {
|
||||
const payload = { ...body, stream: false };
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates tags from LM Studio OpenAI-compatible endpoint.
|
||||
* @param {{ title: string, body: string, tags: string }} payload
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function generateTagsWithLmStudio(payload) {
|
||||
const configuredUrl = process.env.BLOG_EDITOR_LM_STUDIO_URL;
|
||||
const baseUrl = resolveLmStudioBaseUrl(configuredUrl);
|
||||
const endpoints = buildChatEndpointCandidates(baseUrl);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 90000);
|
||||
|
||||
const prompt = [
|
||||
'Generate 3 to 8 relevant blog tags.',
|
||||
'Return at least 1 tag even if the post is sparse.',
|
||||
'Keep any existing tag if it is still relevant.',
|
||||
'Prefer concrete topical tags over generic filler.',
|
||||
`Title: ${payload.title || '(untitled)'}`,
|
||||
`Existing tags: ${payload.tags || '(none)'}`,
|
||||
'Body:',
|
||||
(payload.body || '').slice(0, 6000),
|
||||
].join('\n\n');
|
||||
|
||||
try {
|
||||
log(`AI tags: request started (version=${EXTENSION_VERSION}, title=${payload.title ? 'yes' : 'no'}, bodyChars=${payload.body.length}, existingTags=${payload.tags ? 'yes' : 'no'})`);
|
||||
log(`AI tags: base URL ${baseUrl}`);
|
||||
log(`AI tags: title preview ${JSON.stringify((payload.title || '').slice(0, 120))}`);
|
||||
log(`AI tags: existing tags preview ${JSON.stringify((payload.tags || '').slice(0, 120))}`);
|
||||
log(`AI tags: body preview ${JSON.stringify((payload.body || '').slice(0, 280))}`);
|
||||
const modelId = await detectLmStudioModel(baseUrl, controller.signal);
|
||||
log(`AI tags: using model ${modelId}`);
|
||||
let lastError = '';
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const requestBody = {
|
||||
model: modelId,
|
||||
temperature: 0.2,
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Return only a JSON array that matches the provided schema. Do not include prose, markdown, commentary, or reasoning. The array must contain at least one relevant tag.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'blog_tags',
|
||||
strict: true,
|
||||
schema: BLOG_TAGS_JSON_SCHEMA,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let res = await postLmStudio(endpoint, requestBody, controller.signal);
|
||||
log(`AI tags: chat attempt ${endpoint} -> ${res.status}`);
|
||||
let textOnError = '';
|
||||
if (!res.ok) textOnError = await res.text();
|
||||
|
||||
// Structured output is required here. If an endpoint does not support
|
||||
// response_format/json_schema, skip it and try the next endpoint
|
||||
// instead of falling back to unstructured text generation.
|
||||
if (!res.ok && (res.status === 400 || res.status === 422)) {
|
||||
const unsupported = /response_format|json_schema|unsupported|unknown/i.test(textOnError);
|
||||
if (unsupported) {
|
||||
lastError = `Endpoint ${endpoint} does not support structured output.`;
|
||||
log(`AI tags: ${lastError}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = textOnError || await res.text();
|
||||
const maybeWrongEndpoint = res.status === 404 || res.status === 405 || /not\s*found/i.test(text);
|
||||
if (maybeWrongEndpoint) {
|
||||
lastError = `Endpoint ${endpoint} unavailable (${res.status}).`;
|
||||
log(`AI tags: ${lastError}`);
|
||||
continue;
|
||||
}
|
||||
lastError = `LM Studio request failed (${res.status}) on ${endpoint}: ${text || 'no body'}`;
|
||||
log(`AI tags: ${lastError}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @type {unknown} */
|
||||
const data = await res.json();
|
||||
const content = extractLmStudioMessageContent(data);
|
||||
const parsed = parseTagArray(content);
|
||||
const tags = normalizeGeneratedTags(parsed);
|
||||
if (tags.length === 0) {
|
||||
const fallbackTags = buildFallbackTags(payload);
|
||||
if (fallbackTags.length > 0) {
|
||||
log(`AI tags: LM Studio returned empty structured output, falling back to ${fallbackTags.length} derived tag(s)`);
|
||||
log(`AI tags: fallback tags ${JSON.stringify(fallbackTags)}`);
|
||||
return fallbackTags;
|
||||
}
|
||||
lastError = `LM Studio returned no usable tags from ${endpoint}.`;
|
||||
log(`AI tags: ${lastError}`);
|
||||
continue;
|
||||
}
|
||||
log(`AI tags: success via ${endpoint}, generated ${tags.length} tags`);
|
||||
return tags;
|
||||
}
|
||||
|
||||
throw new Error(lastError || `No LM Studio chat endpoint succeeded. Tried: ${endpoints.join(', ')}`);
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError') {
|
||||
log('AI tags: request timed out after 90s');
|
||||
throw new Error('LM Studio request timed out after 90s.');
|
||||
}
|
||||
log(`AI tags: exception ${String(err)}`);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
log('AI tags: request finished');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
output = vscode.window.createOutputChannel('Blog Editor');
|
||||
context.subscriptions.push(output);
|
||||
log(`Blog Editor activated (version ${EXTENSION_VERSION})`);
|
||||
|
||||
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 author = getPreviousAuthor(blogDir);
|
||||
const authorLine = author ? `\nauthor: "${author.replace(/"/g, '\\"')}"` : '';
|
||||
const starter = `---\ntitle: "${slug}"\ndate: "${now}"${authorLine}\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');
|
||||
const spellcheckPath = path.join(DIST_DIR, 'spellcheck.js');
|
||||
|
||||
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
|
||||
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath) && fs.existsSync(spellcheckPath)) 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, spellcheckResult] = 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,
|
||||
}),
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(EXT_DIR, 'spellcheck.js')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
minify: true,
|
||||
write: false,
|
||||
loader: { '.aff': 'text', '.dic': 'text' },
|
||||
}),
|
||||
]);
|
||||
|
||||
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);
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckResult.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 spellcheckJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'spellcheck.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('{{SPELLCHECK_JS}}', spellcheckJs.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, oldSlug?: string, newSlug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string, title?: string, body?: string, tags?: 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 'generateTags': {
|
||||
const title = (msg.title || '').trim();
|
||||
const body = (msg.body || '').trim();
|
||||
const tags = (msg.tags || '').trim();
|
||||
log('AI tags: webview requested generation');
|
||||
if (!title && !body) {
|
||||
log('AI tags: rejected request - missing title/body');
|
||||
webview.postMessage({ type: 'error', message: 'Need title or body for tag generation.' });
|
||||
return;
|
||||
}
|
||||
webview.postMessage({ type: 'aiTagsStarted' });
|
||||
generateTagsWithLmStudio({ title, body, tags })
|
||||
.then(generated => {
|
||||
log('AI tags: sending generated tags to webview');
|
||||
webview.postMessage({ type: 'aiTagsGenerated', tags: generated });
|
||||
})
|
||||
.catch(err => {
|
||||
log(`AI tags: generation failed ${String(err)}`);
|
||||
webview.postMessage({ type: 'error', message: `AI tag generation failed: ${String(err)}` });
|
||||
});
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
tools/blogeditor/package.json
Normal file
93
tools/blogeditor/package.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "blog-editor",
|
||||
"displayName": "Blog Editor",
|
||||
"description": "WYSIWYG Milkdown blog post editor for this workspace",
|
||||
"version": "0.1.32",
|
||||
"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": {
|
||||
"prepackage": "node ./bump-version.js",
|
||||
"package": "node ./build-dist.js",
|
||||
"postpackage": "npx @vscode/vsce package --out blog-editor.vsix --no-dependencies --allow-missing-repository"
|
||||
}
|
||||
}
|
||||
273
tools/blogeditor/spellcheck.js
Normal file
273
tools/blogeditor/spellcheck.js
Normal file
@@ -0,0 +1,273 @@
|
||||
'use strict';
|
||||
|
||||
import nspell from 'nspell';
|
||||
import affData from 'dictionary-en-us/index.aff';
|
||||
import dicData from 'dictionary-en-us/index.dic';
|
||||
|
||||
const spell = nspell(affData, dicData);
|
||||
const SPELLCHECK_HIGHLIGHT = 'blog-editor-spellcheck';
|
||||
const WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g;
|
||||
const SKIP_TAGS = new Set(['CODE', 'PRE', 'A', 'KBD', 'SAMP']);
|
||||
const PERSONAL_DICTIONARY_KEY = 'blog-editor-personal-dictionary-v1';
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWordChar(value) {
|
||||
return /[A-Za-z'’-]/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight editor spellcheck controller using CSS Highlights.
|
||||
*/
|
||||
export class BlogEditorSpellcheck {
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @param {(message: string, isError?: boolean) => void} updateStatus
|
||||
*/
|
||||
constructor(root, updateStatus) {
|
||||
this.root = root;
|
||||
this.updateStatus = updateStatus;
|
||||
this.enabled = true;
|
||||
this.pendingTimer = 0;
|
||||
this.highlightName = SPELLCHECK_HIGHLIGHT;
|
||||
this.personalWords = new Set();
|
||||
this.supported = typeof CSS !== 'undefined' && typeof CSS.highlights !== 'undefined' && typeof Highlight !== 'undefined';
|
||||
this.loadPersonalDictionary();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled;
|
||||
if (!this.supported) {
|
||||
this.updateStatus(enabled ? 'spellcheck unavailable in this renderer' : 'spellcheck off', !enabled ? false : true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
this.clear();
|
||||
this.updateStatus('spellcheck off');
|
||||
return;
|
||||
}
|
||||
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
schedule() {
|
||||
if (!this.supported || !this.enabled) return;
|
||||
window.clearTimeout(this.pendingTimer);
|
||||
this.pendingTimer = window.setTimeout(() => this.refresh(), 180);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if (!this.supported || !this.enabled) return;
|
||||
const highlight = new Highlight();
|
||||
let count = 0;
|
||||
|
||||
for (const node of this.iterTextNodes()) {
|
||||
const text = node.textContent ?? '';
|
||||
WORD_PATTERN.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = WORD_PATTERN.exec(text)) !== null) {
|
||||
const word = match[0];
|
||||
if (!this.isMisspelledWord(word)) continue;
|
||||
const range = new Range();
|
||||
range.setStart(node, match.index);
|
||||
range.setEnd(node, match.index + word.length);
|
||||
highlight.add(range);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
CSS.highlights.set(this.highlightName, highlight);
|
||||
this.updateStatus(count > 0 ? `spellcheck on · ${count} issue${count === 1 ? '' : 's'}` : 'spellcheck on · clear');
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.supported) return;
|
||||
window.clearTimeout(this.pendingTimer);
|
||||
CSS.highlights.delete(this.highlightName);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the personal dictionary from browser storage.
|
||||
*/
|
||||
loadPersonalDictionary() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
const raw = localStorage.getItem(PERSONAL_DICTIONARY_KEY);
|
||||
if (!raw) return;
|
||||
const words = JSON.parse(raw);
|
||||
if (!Array.isArray(words)) return;
|
||||
for (const entry of words) {
|
||||
if (typeof entry !== 'string' || !entry.trim()) continue;
|
||||
const word = entry.trim();
|
||||
this.personalWords.add(word);
|
||||
spell.add(word);
|
||||
}
|
||||
} catch {
|
||||
this.personalWords.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the personal dictionary to browser storage.
|
||||
*/
|
||||
savePersonalDictionary() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(PERSONAL_DICTIONARY_KEY, JSON.stringify([...this.personalWords].sort((a, b) => a.localeCompare(b))));
|
||||
} catch {
|
||||
// Ignore storage quota / privacy errors.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a word to the personal dictionary and refreshes highlights.
|
||||
* @param {string} word
|
||||
*/
|
||||
addToDictionary(word) {
|
||||
const normalized = typeof word === 'string' ? word.trim() : '';
|
||||
if (!normalized) return;
|
||||
if (this.personalWords.has(normalized)) return;
|
||||
this.personalWords.add(normalized);
|
||||
spell.add(normalized);
|
||||
this.savePersonalDictionary();
|
||||
this.refresh();
|
||||
this.updateStatus(`added "${normalized}" to dictionary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} word
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isMisspelledWord(word) {
|
||||
if (!this.shouldCheckWord(word)) return false;
|
||||
return !spell.correct(word);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} word
|
||||
* @param {number} [limit]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getSuggestions(word, limit = 6) {
|
||||
if (!word || !this.isMisspelledWord(word)) return [];
|
||||
return spell.suggest(word).slice(0, Math.max(0, limit));
|
||||
}
|
||||
|
||||
*iterTextNodes() {
|
||||
const walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: node => {
|
||||
if (!(node.parentElement instanceof HTMLElement)) return NodeFilter.FILTER_REJECT;
|
||||
if (!node.textContent || !WORD_PATTERN.test(node.textContent)) return NodeFilter.FILTER_REJECT;
|
||||
if (node.parentElement.closest('[data-type="image-block"]')) return NodeFilter.FILTER_REJECT;
|
||||
if (this.shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
|
||||
let current;
|
||||
while ((current = walker.nextNode())) {
|
||||
yield current;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldSkip(element) {
|
||||
let current = element;
|
||||
while (current && current !== this.root) {
|
||||
if (SKIP_TAGS.has(current.tagName)) return true;
|
||||
if (current.getAttribute('contenteditable') === 'false') return true;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the misspelled word under a viewport point, if any.
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
* @returns {string | null}
|
||||
*/
|
||||
getMisspelledWordAtPoint(clientX, clientY) {
|
||||
const hit = this.getMisspelledAtPoint(clientX, clientY);
|
||||
return hit ? hit.word : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns misspelled word details under a viewport point, if any.
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
* @returns {{ word: string, range: Range } | null}
|
||||
*/
|
||||
getMisspelledAtPoint(clientX, clientY) {
|
||||
if (!this.supported) return null;
|
||||
|
||||
let node = null;
|
||||
let offset = 0;
|
||||
|
||||
if (typeof document.caretPositionFromPoint === 'function') {
|
||||
const pos = document.caretPositionFromPoint(clientX, clientY);
|
||||
if (pos) {
|
||||
node = pos.offsetNode;
|
||||
offset = pos.offset;
|
||||
}
|
||||
} else if (typeof document.caretRangeFromPoint === 'function') {
|
||||
const range = document.caretRangeFromPoint(clientX, clientY);
|
||||
if (range) {
|
||||
node = range.startContainer;
|
||||
offset = range.startOffset;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(node instanceof Text)) return null;
|
||||
if (!(node.parentElement instanceof HTMLElement)) return null;
|
||||
if (this.shouldSkip(node.parentElement)) return null;
|
||||
|
||||
const text = node.textContent ?? '';
|
||||
if (!text) return null;
|
||||
|
||||
let start = Math.min(Math.max(offset, 0), text.length);
|
||||
let end = start;
|
||||
|
||||
if (start > 0 && !isWordChar(text[start]) && isWordChar(text[start - 1])) {
|
||||
start -= 1;
|
||||
end = start;
|
||||
}
|
||||
|
||||
while (start > 0 && isWordChar(text[start - 1])) start -= 1;
|
||||
while (end < text.length && isWordChar(text[end])) end += 1;
|
||||
|
||||
if (end <= start) return null;
|
||||
|
||||
const word = text.slice(start, end);
|
||||
if (!this.isMisspelledWord(word)) return null;
|
||||
|
||||
const range = new Range();
|
||||
range.setStart(node, start);
|
||||
range.setEnd(node, end);
|
||||
return { word, range };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} word
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldCheckWord(word) {
|
||||
if (word.length < 3) return false;
|
||||
if (/^[A-Z0-9_-]+$/.test(word)) return false;
|
||||
if (/\d/.test(word)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
39
tools/navLinks.js
Normal file
39
tools/navLinks.js
Normal file
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
const GITHUB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>';
|
||||
const BLUESKY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NavLink
|
||||
* @property {string} label
|
||||
* @property {string} href
|
||||
* @property {boolean} [external]
|
||||
* @property {string} [svg] - Inline SVG string shown instead of label text.
|
||||
*/
|
||||
|
||||
/** @type {NavLink[]} */
|
||||
const NAV_LINKS = [
|
||||
{ label: 'home', href: '/' },
|
||||
{ label: 'blog', href: '/blog.html' },
|
||||
{ label: 'docs', href: '/docs/' },
|
||||
{ label: 'materials', href: '/materials' },
|
||||
{ label: 'GitHub', href: 'https://github.com/litruv', external: true, svg: GITHUB_SVG },
|
||||
{ label: 'Bluesky', href: 'https://bsky.app/profile/lit.mates.dev', external: true, svg: BLUESKY_SVG },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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, svg }) => {
|
||||
const attrs = external ? ' target="_blank" rel="noopener"' : '';
|
||||
const content = svg ? svg : label;
|
||||
const extraClass = svg ? ' quick-link--icon' : '';
|
||||
return `${indent}<a href="${href}"${attrs} class="quick-link${extraClass}" aria-label="${label}"${svg ? ` title="${label}"` : ''}>${content}</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 |
BIN
website/data/blog/media/paste-1778545573604-pe0k3.png
Normal file
BIN
website/data/blog/media/paste-1778545573604-pe0k3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
@@ -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,29 @@
|
||||
---
|
||||
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:
|
||||

|
||||
<br />
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
[
|
||||
{
|
||||
"slug": "markdown-test",
|
||||
"title": "Markdown Formatting Test",
|
||||
"date": "2026-04-20",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"slug": "welcome",
|
||||
"title": "Welcome to My Blog",
|
||||
"date": "2026-04-19",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"slug": "node-graphs",
|
||||
"title": "Building Interactive Node Graphs",
|
||||
"date": "2026-04-15",
|
||||
"author": "Max Litruv Boonzaayer",
|
||||
"tags": [
|
||||
"javascript",
|
||||
"graph-systems",
|
||||
"tutorial"
|
||||
],
|
||||
"content": "\r\n# Building Interactive Node Graphs\r\n\r\nIn this post, I'll share some insights on building interactive node-based graph systems for the web.\r\n\r\n## Why Node Graphs?\r\n\r\nNode graphs are powerful visual programming tools that make complex logic easier to understand and manipulate:\r\n\r\n- **Visual Clarity**: See the flow of data and execution at a glance\r\n- **Modularity**: Each node is a self-contained unit\r\n- **Flexibility**: Easy to add new node types and behaviors\r\n\r\n## The Architecture\r\n\r\nThe system is built on several key classes:\r\n\r\n### NodeBase\r\nThe abstract base class that all nodes inherit from. It defines the blueprint interface:\r\n\r\n```javascript\r\nclass NodeBase {\r\n static NodeType = \"\";\r\n static BlueprintPure_GetDefaultPins() { }\r\n BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { }\r\n async BlueprintCallable_Execute(ctx) { }\r\n}\r\n```\r\n\r\n### Node Registry\r\nAutomatically registers node types and creates instances based on type strings.\r\n\r\n### Execution Context\r\nHandles the graph traversal and execution flow when nodes are triggered.\r\n\r\n## Creating Custom Nodes\r\n\r\nTo create a new node type:\r\n\r\n1. Extend `NodeBase`\r\n2. Set a unique `NodeType`\r\n3. Define pins with `BlueprintPure_GetDefaultPins()`\r\n4. Implement rendering in `BlueprintNativeEvent_OnRender()`\r\n5. Implement logic in `BlueprintCallable_Execute()`\r\n\r\nThat's the blueprint system in a nutshell!\r\n"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
5
website/icons/bluesky-black-icon.svg
Normal file
5
website/icons/bluesky-black-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 453" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="M110.985,30.441C169.68,74.658 232.822,164.297 255.998,212.402C279.175,164.297 342.321,74.658 401.015,30.441C443.376,-1.455 512,-26.142 512,52.402C512,68.082 503.038,184.177 497.777,203.03C479.504,268.545 412.904,285.258 353.665,275.146C457.215,292.825 483.554,351.383 426.665,409.945C318.624,521.168 271.377,382.04 259.279,346.391C255.791,336.129 256.288,335.893 252.719,346.391C240.621,382.04 193.377,521.168 85.336,409.945C28.447,351.383 54.785,292.825 158.335,275.146C99.096,285.258 32.495,268.545 14.223,203.03C8.962,184.177 0,68.082 0,52.402C0,-26.142 68.633,-1.455 110.985,30.441ZM96.799,49.275C84.105,39.715 68.726,30.778 54.004,26.256C44.996,23.49 36.38,21.013 30.395,25.642C24.778,29.987 23.579,38.884 23.579,52.402C23.579,67.42 31.895,178.636 36.934,196.692L36.935,196.696C51.648,249.45 106.666,260.046 154.367,251.903C167.204,249.712 179.386,258.342 181.578,271.178C183.769,284.015 175.14,296.197 162.303,298.389C135.291,303 114.19,310.441 99.405,320.491C88.594,327.839 81.552,336.27 79.887,346.191C77.4,361.01 86.042,376.833 102.249,393.516C127.513,419.524 147.307,430.205 164.581,428.535C178.755,427.164 189.413,417.046 198.267,405.504C215.828,382.612 225.787,352.38 230.391,338.814L230.395,338.801C232.485,332.655 234.064,328.904 235.05,327.229L238.226,322.916L243.134,318.787L249.091,316.051L256.946,315.045L263.15,316.115L269.971,319.512L274.366,323.551L277.231,327.669C278.172,329.34 279.629,332.994 281.604,338.803L281.607,338.814C286.211,352.38 296.171,382.612 313.732,405.504C322.586,417.046 333.245,427.164 347.419,428.535C364.693,430.205 384.488,419.524 409.752,393.516C425.959,376.833 434.601,361.01 432.114,346.191C430.449,336.27 423.406,327.84 412.596,320.491C397.81,310.441 376.709,303 349.697,298.389C336.86,296.197 328.231,284.015 330.422,271.178C332.614,258.342 344.796,249.712 357.633,251.903C405.334,260.046 460.351,249.45 475.065,196.695L475.066,196.692C480.105,178.636 488.421,67.42 488.421,52.402C488.421,38.884 487.222,29.987 481.606,25.642C475.622,21.012 467.007,23.49 458,26.256C443.277,30.778 427.897,39.716 415.2,49.276C359.312,91.38 299.309,176.831 277.24,222.636C273.309,230.795 265.054,235.981 255.998,235.981C246.942,235.981 238.686,230.794 234.756,222.636C212.688,176.831 152.688,91.379 96.799,49.275Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
4
website/icons/github-outline.svg
Normal file
4
website/icons/github-outline.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.475 2 2 6.475 2 12C2 16.425 4.8625 20.1625 8.8375 21.4875C9.3375 21.575 9.525 21.275 9.525 21.0125C9.525 20.775 9.5125 19.9875 9.5125 19.15C7 19.6125 6.35 18.5375 6.15 17.975C6.0375 17.6875 5.55 16.8 5.125 16.5625C4.775 16.375 4.275 15.9125 5.1125 15.9C5.9 15.8875 6.4625 16.625 6.65 16.925C7.55 18.4375 8.9875 18.0125 9.5625 17.75C9.65 17.1 9.9125 16.6625 10.2 16.4125C7.975 16.1625 5.65 15.3 5.65 11.475C5.65 10.3875 6.0375 9.4875 6.675 8.7875C6.575 8.5375 6.225 7.5125 6.775 6.1375C6.775 6.1375 7.6125 5.875 9.525 7.1625C10.325 6.9375 11.175 6.825 12.025 6.825C12.875 6.825 13.725 6.9375 14.525 7.1625C16.4375 5.8625 17.275 6.1375 17.275 6.1375C17.825 7.5125 17.475 8.5375 17.375 8.7875C18.0125 9.4875 18.4 10.375 18.4 11.475C18.4 15.3125 16.0625 16.1625 13.8375 16.4125C14.2 16.725 14.5125 17.325 14.5125 18.2625C14.5125 19.6 14.5 20.675 14.5 21.0125C14.5 21.275 14.6875 21.5875 15.1875 21.4875C17.1727 20.8173 18.8977 19.5415 20.1198 17.8395C21.3419 16.1376 21.9995 14.0953 22 12C22 6.475 17.525 2 12 2Z" stroke="#000000" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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>
|
||||
|
||||
1428
website/scripts/BlogComments.js
Normal file
1428
website/scripts/BlogComments.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
242
website/scripts/blogPage.js
Normal file
242
website/scripts/blogPage.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer.js";
|
||||
import { BlogComments } from "./BlogComments.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts Matrix-backed comments/reactions for the current blog post.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function setupBlogComments() {
|
||||
/** @type {HTMLElement | null} */
|
||||
const mount = document.querySelector("[data-blog-comments]");
|
||||
if (!mount) return;
|
||||
|
||||
const slug = window.location.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop() || "unknown";
|
||||
|
||||
const homeserver = mount.dataset.matrixHomeserver || "https://chat.ruv.wtf";
|
||||
const roomAlias = mount.dataset.matrixRoom || "#general:chat.ruv.wtf";
|
||||
|
||||
const comments = new BlogComments({
|
||||
homeserver,
|
||||
roomAlias,
|
||||
postSlug: slug
|
||||
});
|
||||
|
||||
await comments.initialize(mount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]");
|
||||
|
||||
if (!bar) return;
|
||||
|
||||
let lastScrollTop = 0;
|
||||
|
||||
const applyState = () => {
|
||||
const scrollTop = body.scrollTop;
|
||||
const scrollingDown = scrollTop > lastScrollTop;
|
||||
lastScrollTop = scrollTop;
|
||||
|
||||
if (scrollTop < 80) {
|
||||
body.classList.remove("blog-scrolled");
|
||||
} else if (scrollingDown) {
|
||||
body.classList.remove("blog-scrolled");
|
||||
} else {
|
||||
body.classList.add("blog-scrolled");
|
||||
}
|
||||
};
|
||||
body.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}
|
||||
*/
|
||||
async function initializeBlogPage() {
|
||||
renderStaticBlogPost();
|
||||
setupScrollTopBar();
|
||||
setupPeekSplines();
|
||||
await setupBlogComments();
|
||||
}
|
||||
|
||||
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