Compare commits
46 Commits
1f658c179f
...
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 | |||
| 40fd8e8e42 | |||
| 34c2aa4316 | |||
| 7e7e6f2c22 | |||
| 3c45406f6f | |||
| f40856d747 | |||
| 17e52681d7 | |||
| d762f8e67e | |||
| df10268a83 | |||
| 6d7ee1db71 |
472
build.js
@@ -1,5 +1,244 @@
|
||||
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
|
||||
* @param {string} content - The markdown file content
|
||||
* @returns {{metadata: Object, content: string}}
|
||||
*/
|
||||
function parseMarkdownWithFrontmatter(content) {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { metadata: {}, content: content };
|
||||
}
|
||||
|
||||
const yamlContent = match[1];
|
||||
const markdownContent = match[2];
|
||||
|
||||
// Simple YAML parser for basic key: value pairs
|
||||
const metadata = {};
|
||||
const lines = yamlContent.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
// Remove quotes if present
|
||||
metadata[key] = value.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, content: markdownContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process blog markdown files and generate blogs.json, also update graph.json
|
||||
* @param {string} buildDir - Path to the build output directory
|
||||
*/
|
||||
async function processBlogPosts(buildDir) {
|
||||
const blogDir = path.join(__dirname, 'website', 'data', 'blog');
|
||||
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);
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||
|
||||
const posts = [];
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const filePath = path.join(blogDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const { metadata, content: markdown } = parseMarkdownWithFrontmatter(content);
|
||||
|
||||
const slug = path.basename(file, '.md');
|
||||
|
||||
// Extract and process tags separately
|
||||
const tags = metadata.tags ? metadata.tags.split(',').map(t => t.trim()) : [];
|
||||
|
||||
// Remove processed fields from metadata
|
||||
const { tags: _tags, title, date, author, ...extraMetadata } = metadata;
|
||||
|
||||
posts.push({
|
||||
slug,
|
||||
title: title || slug,
|
||||
date: date || null,
|
||||
author: author || null,
|
||||
tags,
|
||||
content: markdown,
|
||||
...extraMetadata
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
posts.sort((a, b) => {
|
||||
if (!a.date) return 1;
|
||||
if (!b.date) return -1;
|
||||
return new Date(b.date) - new Date(a.date);
|
||||
});
|
||||
|
||||
await fs.writeFile(outputPath, JSON.stringify(posts, null, 2));
|
||||
console.log(`✓ Processed ${posts.length} blog post(s)`);
|
||||
|
||||
// Update graph.json with blog nodes
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update graph.json with blog post nodes
|
||||
* @param {Array} posts - Array of blog post objects
|
||||
* @param {string} graphPath - Path to graph.json
|
||||
*/
|
||||
async function updateGraphWithBlogNodes(posts, graphPath) {
|
||||
const graphData = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
|
||||
|
||||
// Remove existing blog nodes and their connections
|
||||
graphData.nodes = graphData.nodes.filter(node => node.type !== 'blog_post');
|
||||
graphData.connections = graphData.connections.filter(conn => {
|
||||
const fromNode = graphData.nodes.find(n => n.id === conn.from.nodeId);
|
||||
const toNode = graphData.nodes.find(n => n.id === conn.to.nodeId);
|
||||
return fromNode && toNode;
|
||||
});
|
||||
|
||||
const startX = 1500;
|
||||
const startY = 900;
|
||||
const horizontalSpacing = 700;
|
||||
const nodeWidth = 600;
|
||||
|
||||
const blogNodes = [];
|
||||
const blogConnections = [];
|
||||
|
||||
posts.forEach((post, index) => {
|
||||
const nodeId = `blog_${post.slug}`;
|
||||
const x = startX + (index * horizontalSpacing);
|
||||
|
||||
const node = {
|
||||
id: nodeId,
|
||||
type: 'blog_post',
|
||||
title: post.title,
|
||||
blogSlug: post.slug,
|
||||
position: {
|
||||
x: x,
|
||||
y: startY
|
||||
},
|
||||
width: nodeWidth,
|
||||
inputs: index > 0 ? [{
|
||||
id: 'exec_in',
|
||||
name: 'Previous',
|
||||
direction: 'input',
|
||||
kind: 'exec'
|
||||
}] : [],
|
||||
outputs: index < posts.length - 1 ? [{
|
||||
id: 'exec_out',
|
||||
name: 'Next',
|
||||
direction: 'output',
|
||||
kind: 'exec'
|
||||
}] : []
|
||||
};
|
||||
|
||||
blogNodes.push(node);
|
||||
|
||||
// Create connection to next post
|
||||
if (index < posts.length - 1) {
|
||||
const nextNodeId = `blog_${posts[index + 1].slug}`;
|
||||
blogConnections.push({
|
||||
id: `blog_connection_${index}`,
|
||||
from: {
|
||||
nodeId: nodeId,
|
||||
pinId: 'exec_out'
|
||||
},
|
||||
to: {
|
||||
nodeId: nextNodeId,
|
||||
pinId: 'exec_in'
|
||||
},
|
||||
kind: 'exec'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add blog nodes and connections to graph
|
||||
graphData.nodes.push(...blogNodes);
|
||||
graphData.connections.push(...blogConnections);
|
||||
|
||||
await fs.writeFile(graphPath, JSON.stringify(graphData, null, 2));
|
||||
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() {
|
||||
@@ -42,5 +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
|
||||
copyWebsiteFiles();
|
||||
async function build() {
|
||||
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();
|
||||
|
||||
53
chromakey.js
@@ -1,53 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Using Jimp for image processing
|
||||
const { Jimp } = require('jimp');
|
||||
|
||||
async function chromakeyImage() {
|
||||
try {
|
||||
const inputPath = path.join(__dirname, 'website', 'screenborder.jpg');
|
||||
const outputPath = path.join(__dirname, 'website', 'screenborder.png');
|
||||
|
||||
console.log('Loading image...');
|
||||
const image = await Jimp.read(inputPath);
|
||||
|
||||
console.log('Removing green screen...');
|
||||
|
||||
// Scan through each pixel
|
||||
image.scan(0, 0, image.bitmap.width, image.bitmap.height, function(x, y, idx) {
|
||||
const red = this.bitmap.data[idx + 0];
|
||||
const green = this.bitmap.data[idx + 1];
|
||||
const blue = this.bitmap.data[idx + 2];
|
||||
const alpha = this.bitmap.data[idx + 3];
|
||||
|
||||
// Very aggressive green detection
|
||||
const isGreen = green > red * 1.05 && green > blue * 1.05 && green > 30;
|
||||
|
||||
// Catch any bright greens
|
||||
const isBrightGreen = green > 120 && green > red && green > blue;
|
||||
|
||||
if (isGreen || isBrightGreen) {
|
||||
// Make pixel transparent
|
||||
this.bitmap.data[idx + 3] = 0;
|
||||
} else if (green > red || green > blue) {
|
||||
// Aggressively reduce alpha for any greenish tint
|
||||
const greenness = Math.max(
|
||||
(green - red) / 255,
|
||||
(green - blue) / 255
|
||||
);
|
||||
this.bitmap.data[idx + 3] = Math.floor(alpha * (1 - greenness));
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Saving PNG...');
|
||||
await image.write(outputPath);
|
||||
|
||||
console.log('✓ Chromakey complete! Saved to website/screenborder.png');
|
||||
} catch (error) {
|
||||
console.error('✗ Chromakey failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
chromakeyImage();
|
||||
@@ -1,46 +0,0 @@
|
||||
const { Jimp } = require('jimp');
|
||||
const path = require('path');
|
||||
|
||||
async function createDisplacementMap() {
|
||||
const size = 512;
|
||||
const image = new Jimp({ width: size, height: size });
|
||||
|
||||
// Create a gentle CRT bulge displacement map
|
||||
// Center appears to bulge outward (magnified)
|
||||
// R channel = horizontal displacement
|
||||
// G channel = vertical displacement
|
||||
// 128 = no displacement, <128 = pull toward 0, >128 = push toward max
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
// Normalize to -1 to 1 range (center is 0,0)
|
||||
const nx = (x / (size - 1)) * 2 - 1;
|
||||
const ny = (y / (size - 1)) * 2 - 1;
|
||||
|
||||
// Use smooth cubic falloff for gentle curve
|
||||
// Pull edges slightly toward center (pincushion)
|
||||
const distSq = nx * nx + ny * ny;
|
||||
const strength = distSq * 0.15; // Gentle effect
|
||||
|
||||
// Direction away from center (positive = away from center)
|
||||
const dx = nx * strength;
|
||||
const dy = ny * strength;
|
||||
|
||||
// Convert to 0-255 range (128 = no displacement)
|
||||
const r = Math.floor(128 + dx * 127);
|
||||
const g = Math.floor(128 + dy * 127);
|
||||
|
||||
const idx = (y * size + x) * 4;
|
||||
image.bitmap.data[idx + 0] = Math.max(0, Math.min(255, r));
|
||||
image.bitmap.data[idx + 1] = Math.max(0, Math.min(255, g));
|
||||
image.bitmap.data[idx + 2] = 128;
|
||||
image.bitmap.data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = path.join(__dirname, 'website', 'bulge-map.png');
|
||||
await image.write(outputPath);
|
||||
console.log('✓ Displacement map created: website/bulge-map.png');
|
||||
}
|
||||
|
||||
createDisplacementMap();
|
||||
3877
package-lock.json
generated
13
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",
|
||||
@@ -20,11 +21,19 @@
|
||||
"author": "Max Litruv Boonzaayer",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@litruv/mxjs-lite": "^1.0.1",
|
||||
"jimp": "^1.6.0"
|
||||
},
|
||||
"repository": {
|
||||
"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
@@ -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
@@ -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.vsix
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
@@ -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
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 379 B |
|
Before Width: | Height: | Size: 310 B |
|
Before Width: | Height: | Size: 367 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 208 B |
|
Before Width: | Height: | Size: 470 B |
|
Before Width: | Height: | Size: 438 B |
|
Before Width: | Height: | Size: 813 B |
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 958 B |
|
Before Width: | Height: | Size: 225 B |
|
Before Width: | Height: | Size: 375 B |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 513 B |
|
Before Width: | Height: | Size: 506 B |
|
Before Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 430 B |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 144 B |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 199 B |
|
Before Width: | Height: | Size: 108 B |
|
Before Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 187 B |
|
Before Width: | Height: | Size: 306 B |
|
Before Width: | Height: | Size: 242 B |
|
Before Width: | Height: | Size: 364 B |
|
Before Width: | Height: | Size: 534 B |
|
Before Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 584 B |
|
Before Width: | Height: | Size: 368 B |
|
Before Width: | Height: | Size: 257 B |
|
Before Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 568 B |
|
Before Width: | Height: | Size: 632 B |
|
Before Width: | Height: | Size: 248 B |
|
Before Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 349 B |
|
Before Width: | Height: | Size: 675 B |
|
Before Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 386 B |
|
Before Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 206 B |
|
Before Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 102 B |
|
Before Width: | Height: | Size: 115 B |
|
Before Width: | Height: | Size: 123 B |
|
Before Width: | Height: | Size: 140 B |
|
Before Width: | Height: | Size: 143 B |
|
Before Width: | Height: | Size: 159 B |
|
Before Width: | Height: | Size: 160 B |
|
Before Width: | Height: | Size: 174 B |
|
Before Width: | Height: | Size: 180 B |
|
Before Width: | Height: | Size: 180 B |
|
Before Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 266 B |
|
Before Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 823 B |
|
Before Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 629 B |
|
Before Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 707 B |
|
Before Width: | Height: | Size: 639 B |
|
Before Width: | Height: | Size: 626 B |
|
Before Width: | Height: | Size: 551 B |
|
Before Width: | Height: | Size: 449 B |
|
Before Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 465 B |
|
Before Width: | Height: | Size: 417 B |
|
Before Width: | Height: | Size: 398 B |
|
Before Width: | Height: | Size: 399 B |